diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 30e6d46..0000000 --- a/.editorconfig +++ /dev/null @@ -1,8 +0,0 @@ -# http://editorconfig.org - -root = true - -[*] -charset = utf-8 -indent_style = tab -insert_final_newline = true diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 176a458..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text=auto diff --git a/.gitignore b/.gitignore index d1502b0..b26a347 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ vendor/ composer.lock +.php-cs-fixer.cache +.idea/ diff --git a/Examples/Example.php b/Examples/Example.php index caa7efb..a8bc770 100644 --- a/Examples/Example.php +++ b/Examples/Example.php @@ -1,34 +1,32 @@ - define( 'SQ_SERVER_ADDR', 'localhost' ); - define( 'SQ_SERVER_PORT', 27015 ); - define( 'SQ_TIMEOUT', 1 ); - define( 'SQ_ENGINE', SourceQuery::SOURCE ); - // Edit this <- - - $Query = new SourceQuery( ); - - try - { - $Query->Connect( SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE ); - - print_r( $Query->GetInfo( ) ); - print_r( $Query->GetPlayers( ) ); - print_r( $Query->GetRules( ) ); - } - catch( Exception $e ) - { - echo $e->getMessage( ); - } - finally - { - $Query->Disconnect( ); - } + +declare(strict_types=1); + +require __DIR__ . '/../SourceQuery/bootstrap.php'; + +use xPaw\SourceQuery\SourceQuery; + +// For the sake of this example +header('Content-Type: text/plain'); +header('X-Content-Type-Options: nosniff'); + +// Edit this -> +define('SQ_SERVER_ADDR', 'localhost'); +define('SQ_SERVER_PORT', 27015); +define('SQ_TIMEOUT', 1); +define('SQ_ENGINE', SourceQuery::SOURCE); +// Edit this <- + +$Query = new SourceQuery(); + +try { + $Query->Connect(SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE); + + print_r($Query->GetInfo()); + print_r($Query->GetPlayers()); + print_r($Query->GetRules()); +} catch (Exception $e) { + echo $e->getMessage(); +} finally { + $Query->Disconnect(); +} diff --git a/Examples/RconExample.php b/Examples/RconExample.php index 5888415..d1e22de 100644 --- a/Examples/RconExample.php +++ b/Examples/RconExample.php @@ -1,34 +1,32 @@ - define( 'SQ_SERVER_ADDR', 'localhost' ); - define( 'SQ_SERVER_PORT', 27015 ); - define( 'SQ_TIMEOUT', 1 ); - define( 'SQ_ENGINE', SourceQuery::SOURCE ); - // Edit this <- - - $Query = new SourceQuery( ); - - try - { - $Query->Connect( SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE ); - - $Query->SetRconPassword( 'my_awesome_password' ); - - var_dump( $Query->Rcon( 'say hello' ) ); - } - catch( Exception $e ) - { - echo $e->getMessage( ); - } - finally - { - $Query->Disconnect( ); - } + +declare(strict_types=1); + +require __DIR__ . '/../SourceQuery/bootstrap.php'; + +use xPaw\SourceQuery\SourceQuery; + +// For the sake of this example +header('Content-Type: text/plain'); +header('X-Content-Type-Options: nosniff'); + +// Edit this -> +define('SQ_SERVER_ADDR', 'localhost'); +define('SQ_SERVER_PORT', 27015); +define('SQ_TIMEOUT', 1); +define('SQ_ENGINE', SourceQuery::SOURCE); +// Edit this <- + +$Query = new SourceQuery(); + +try { + $Query->Connect(SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE); + + $Query->SetRconPassword('my_awesome_password'); + + var_dump($Query->Rcon('say hello')); +} catch (Exception $e) { + echo $e->getMessage(); +} finally { + $Query->Disconnect(); +} diff --git a/Examples/View.php b/Examples/View.php index 11cb15e..fb21e20 100644 --- a/Examples/View.php +++ b/Examples/View.php @@ -1,43 +1,42 @@ - define( 'SQ_SERVER_ADDR', 'localhost' ); - define( 'SQ_SERVER_PORT', 27015 ); - define( 'SQ_TIMEOUT', 3 ); - define( 'SQ_ENGINE', SourceQuery::SOURCE ); - // Edit this <- - - $Timer = microtime( true ); - - $Query = new SourceQuery( ); - - $Info = []; - $Rules = []; - $Players = []; - $Exception = null; - - try - { - $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 - - $Info = $Query->GetInfo( ); - $Players = $Query->GetPlayers( ); - $Rules = $Query->GetRules( ); - } - catch( Exception $e ) - { - $Exception = $e; - } - finally - { - $Query->Disconnect( ); - } - - $Timer = number_format( microtime( true ) - $Timer, 4, '.', '' ); +declare(strict_types=1); + +require __DIR__ . '/../SourceQuery/bootstrap.php'; + +use xPaw\SourceQuery\SourceQuery; + +// Edit this -> +define('SQ_SERVER_ADDR', 'localhost'); +define('SQ_SERVER_PORT', 27015); +define('SQ_TIMEOUT', 3); +define('SQ_ENGINE', SourceQuery::SOURCE); +// Edit this <- + +$Timer = microtime(true); + +$Query = new SourceQuery(); + +$Info = []; +$Rules = []; +$Players = []; +$Exception = null; + +try { + $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 + + $Info = $Query->GetInfo(); + $Players = $Query->GetPlayers(); + $Rules = $Query->GetRules(); +} catch (Exception $e) { + $Exception = $e; +} finally { + $Query->Disconnect(); +} + +$Timer = number_format(microtime(true) - $Timer, 4, '.', ''); + ?> @@ -88,9 +87,9 @@
- +
-
__toString( ) ); ?>
+
__toString()); ?>
@@ -103,32 +102,24 @@ - - $InfoValue ): ?> + + $InfoValue): ?> - + "; - print_r( $InfoValue ); - echo ""; - } - else - { - if( $InfoValue === true ) - { - echo 'true'; - } - else if( $InfoValue === false ) - { - echo 'false'; - } - else - { - echo htmlspecialchars( $InfoValue ); - } - } + if (is_array($InfoValue)) { + echo "
";
+        print_r($InfoValue);
+        echo "
"; + } else { + if ($InfoValue === true) { + echo 'true'; + } elseif ($InfoValue === false) { + echo 'false'; + } else { + echo htmlspecialchars($InfoValue); + } + } ?> @@ -144,16 +135,16 @@ - + - - + + - + @@ -172,15 +163,15 @@
Player Player Frags Time
- + - - $Value ): ?> + + $Value): ?> - - + + diff --git a/SourceQuery/BaseSocket.php b/SourceQuery/BaseSocket.php index 1ce405e..5909f48 100644 --- a/SourceQuery/BaseSocket.php +++ b/SourceQuery/BaseSocket.php @@ -1,139 +1,127 @@ Close( ); - } - - abstract public function Close( ) : void; - abstract public function Open( string $Address, int $Port, int $Timeout, int $Engine ) : void; - abstract public function Write( int $Header, string $String = '' ) : bool; - abstract public function Read( int $Length = 1400 ) : Buffer; - - 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( ); - - if( $Header === -1 ) // Single packet - { - // We don't have to do anything - } - else if( $Header === -2 ) // Split packet - { - $Packets = []; - $IsCompressed = false; - $ReadMore = 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 > sizeof( $Packets ); - } - while( $ReadMore && $SherlockFunction( $Buffer, $Length ) ); - - $Data = implode( $Packets ); - - // TODO: Test this - if( $IsCompressed ) - { - // Let's make sure this function exists, it's not included in PHP by default - if( !function_exists( 'bzdecompress' ) ) - { - throw new \RuntimeException( 'Received compressed packet, PHP doesn\'t have Bzip2 library installed, can\'t decompress.' ); - } - - $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; - } - } + +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 xPaw\SourceQuery\Exception\InvalidPacketException + * @uses xPaw\SourceQuery\Exception\SocketException + */ +abstract class BaseSocket +{ + /** @var ?resource */ + public $Socket; + public int $Engine; + + public string $Address; + public int $Port; + public int $Timeout; + + public function __destruct() + { + $this->Close(); + } + + abstract public function Close(): void; + abstract public function Open(string $Address, int $Port, int $Timeout, int $Engine): void; + abstract public function Write(int $Header, string $String = ''): bool; + abstract public function Read(int $Length = 1400): Buffer; + + 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(); + + if ($Header === -1) { // Single packet + // We don't have to do anything + } elseif ($Header === -2) { // Split packet + $Packets = []; + $IsCompressed = false; + $ReadMore = 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 > sizeof($Packets); + } while ($ReadMore && $SherlockFunction($Buffer, $Length)); + + $Data = implode($Packets); + + // TODO: Test this + if ($IsCompressed) { + // Let's make sure this function exists, it's not included in PHP by default + if (!function_exists('bzdecompress')) { + throw new \RuntimeException('Received compressed packet, PHP doesn\'t have Bzip2 library installed, can\'t decompress.'); + } + + $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; + } +} diff --git a/SourceQuery/Buffer.php b/SourceQuery/Buffer.php index 2452c99..1896261 100644 --- a/SourceQuery/Buffer.php +++ b/SourceQuery/Buffer.php @@ -1,177 +1,171 @@ Buffer = $Buffer; - $this->Length = strlen( $Buffer ); - $this->Position = 0; - } - - /** - * Get remaining bytes - * - * @return int Remaining bytes in buffer - */ - public function Remaining( ) : int - { - return $this->Length - $this->Position; - } - - /** - * Gets data from buffer - * - * @param int $Length Bytes to read - */ - public function Get( int $Length = -1 ) : string - { - if( $Length === 0 ) - { - return ''; - } - - $Remaining = $this->Remaining( ); - - if( $Length === -1 ) - { - $Length = $Remaining; - } - else if( $Length > $Remaining ) - { - return ''; - } - - $Data = substr( $this->Buffer, $this->Position, $Length ); - - $this->Position += $Length; - - return $Data; - } - - /** - * Get byte from buffer - */ - public function GetByte( ) : int - { - return ord( $this->Get( 1 ) ); - } - - /** - * Get short from buffer - */ - public function GetShort( ) : int - { - if( $this->Remaining( ) < 2 ) - { - throw new InvalidPacketException( 'Not enough data to unpack a short.', InvalidPacketException::BUFFER_EMPTY ); - } - - $Data = unpack( 'v', $this->Get( 2 ) ); - - return (int)$Data[ 1 ]; - } - - /** - * Get long from buffer - */ - public function GetLong( ) : int - { - if( $this->Remaining( ) < 4 ) - { - throw new InvalidPacketException( 'Not enough data to unpack a long.', InvalidPacketException::BUFFER_EMPTY ); - } - - $Data = unpack( 'l', $this->Get( 4 ) ); - - return (int)$Data[ 1 ]; - } - - /** - * Get float from buffer - */ - public function GetFloat( ) : float - { - if( $this->Remaining( ) < 4 ) - { - throw new InvalidPacketException( 'Not enough data to unpack a float.', InvalidPacketException::BUFFER_EMPTY ); - } - - $Data = unpack( 'f', $this->Get( 4 ) ); - - return (float)$Data[ 1 ]; - } - - /** - * Get unsigned long from buffer - */ - public function GetUnsignedLong( ) : int - { - if( $this->Remaining( ) < 4 ) - { - throw new InvalidPacketException( 'Not enough data to unpack an usigned long.', InvalidPacketException::BUFFER_EMPTY ); - } - - $Data = unpack( 'V', $this->Get( 4 ) ); - - return (int)$Data[ 1 ]; - } - - /** - * Read one string from buffer ending with null byte - */ - public function GetString( ) : string - { - $ZeroBytePosition = strpos( $this->Buffer, "\0", $this->Position ); - - if( $ZeroBytePosition === false ) - { - return ''; - } - - $String = $this->Get( $ZeroBytePosition - $this->Position ); - - $this->Position++; - - return $String; - } - } + +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; + +/** + * Class Buffer + * + * @package xPaw\SourceQuery + * + * @uses xPaw\SourceQuery\Exception\InvalidPacketException + */ +final class Buffer +{ + /** + * Buffer + */ + private string $Buffer = ''; + + /** + * Buffer length + */ + private int $Length = 0; + + /** + * Current position in buffer + */ + private int $Position = 0; + + /** + * Sets buffer + */ + public function Set(string $Buffer): void + { + $this->Buffer = $Buffer; + $this->Length = strlen($Buffer); + $this->Position = 0; + } + + /** + * Get remaining bytes + * + * @return int Remaining bytes in buffer + */ + public function Remaining(): int + { + return $this->Length - $this->Position; + } + + /** + * Gets data from buffer + * + * @param int $Length Bytes to read + */ + public function Get(int $Length = -1): string + { + if ($Length === 0) { + return ''; + } + + $Remaining = $this->Remaining(); + + if ($Length === -1) { + $Length = $Remaining; + } elseif ($Length > $Remaining) { + return ''; + } + + $Data = substr($this->Buffer, $this->Position, $Length); + + $this->Position += $Length; + + return $Data; + } + + /** + * Get byte from buffer + */ + public function GetByte(): int + { + return ord($this->Get(1)); + } + + /** + * Get short from buffer + */ + public function GetShort(): int + { + if ($this->Remaining() < 2) { + throw new InvalidPacketException('Not enough data to unpack a short.', InvalidPacketException::BUFFER_EMPTY); + } + + $Data = unpack('v', $this->Get(2)); + + return (int)$Data[ 1 ]; + } + + /** + * Get long from buffer + */ + public function GetLong(): int + { + if ($this->Remaining() < 4) { + throw new InvalidPacketException('Not enough data to unpack a long.', InvalidPacketException::BUFFER_EMPTY); + } + + $Data = unpack('l', $this->Get(4)); + + return (int)$Data[ 1 ]; + } + + /** + * Get float from buffer + */ + public function GetFloat(): float + { + if ($this->Remaining() < 4) { + throw new InvalidPacketException('Not enough data to unpack a float.', InvalidPacketException::BUFFER_EMPTY); + } + + $Data = unpack('f', $this->Get(4)); + + return (float)$Data[ 1 ]; + } + + /** + * Get unsigned long from buffer + */ + public function GetUnsignedLong(): int + { + if ($this->Remaining() < 4) { + throw new InvalidPacketException('Not enough data to unpack an usigned long.', InvalidPacketException::BUFFER_EMPTY); + } + + $Data = unpack('V', $this->Get(4)); + + return (int)$Data[ 1 ]; + } + + /** + * Read one string from buffer ending with null byte + */ + public function GetString(): string + { + $ZeroBytePosition = strpos($this->Buffer, "\0", $this->Position); + + if ($ZeroBytePosition === false) { + return ''; + } + + $String = $this->Get($ZeroBytePosition - $this->Position); + + $this->Position++; + + return $String; + } +} diff --git a/SourceQuery/Exception/AuthenticationException.php b/SourceQuery/Exception/AuthenticationException.php index 3e90f6e..2dd78c9 100644 --- a/SourceQuery/Exception/AuthenticationException.php +++ b/SourceQuery/Exception/AuthenticationException.php @@ -1,19 +1,22 @@ Socket = $Socket; - } - - public function Close( ) : void - { - $this->RconChallenge = ''; - $this->RconPassword = ''; - } - - public function Open( ) : void - { - // - } - - public function Write( int $Header, string $String = '' ) : bool - { - $Command = pack( 'cccca*', 0xFF, 0xFF, 0xFF, 0xFF, $String ); - $Length = strlen( $Command ); - - return $Length === fwrite( $this->Socket->Socket, $Command, $Length ); - } - - /** - * @param int $Length - * @throws AuthenticationException - * @return Buffer - */ - public function Read( int $Length = 1400 ) : Buffer - { - // GoldSource RCON has same structure as Query - $Buffer = $this->Socket->Read( ); - - $StringBuffer = ''; - $ReadMore = false; - - // 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 ); - } - else if( $Trimmed === 'You have been banned from this server.' ) - { - throw new AuthenticationException( $Trimmed, AuthenticationException::BANNED ); - } - - $Buffer->Set( $Trimmed ); - - return $Buffer; - } - - 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( 0, 'rcon ' . $this->RconChallenge . ' "' . $this->RconPassword . '" ' . $Command . "\0" ); - $Buffer = $this->Read( ); - - return $Buffer->Get( ); - } - - public function Authorize( string $Password ) : void - { - $this->RconPassword = $Password; - - $this->Write( 0, '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( ) ); - } - } + +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 $Socket; + + private string $RconPassword = ''; + private string $RconChallenge = ''; + + public function __construct(BaseSocket $Socket) + { + $this->Socket = $Socket; + } + + public function Close(): void + { + $this->RconChallenge = ''; + $this->RconPassword = ''; + } + + public function Open(): void + { + // + } + + public function Write(int $Header, string $String = ''): bool + { + $Command = pack('cccca*', 0xFF, 0xFF, 0xFF, 0xFF, $String); + $Length = strlen($Command); + + return $Length === fwrite($this->Socket->Socket, $Command, $Length); + } + + /** + * @param int $Length + * @throws AuthenticationException + * @return Buffer + */ + public function Read(int $Length = 1400): Buffer + { + // GoldSource RCON has same structure as Query + $Buffer = $this->Socket->Read(); + + $StringBuffer = ''; + $ReadMore = false; + + // 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; + } + + 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(0, 'rcon ' . $this->RconChallenge . ' "' . $this->RconPassword . '" ' . $Command . "\0"); + $Buffer = $this->Read(); + + return $Buffer->Get(); + } + + public function Authorize(string $Password): void + { + $this->RconPassword = $Password; + + $this->Write(0, '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()); + } +} diff --git a/SourceQuery/Socket.php b/SourceQuery/Socket.php index 726a4d1..f5d8bb5 100644 --- a/SourceQuery/Socket.php +++ b/SourceQuery/Socket.php @@ -1,95 +1,95 @@ Socket !== null ) - { - fclose( $this->Socket ); - - $this->Socket = null; - } - } - - 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 ); - } - - 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. - * - * @throws InvalidPacketException - * - * @return Buffer Buffer - */ - public function Read( int $Length = 1400 ) : Buffer - { - $Buffer = new Buffer( ); - $Buffer->Set( fread( $this->Socket, $Length ) ); - - $this->ReadInternal( $Buffer, $Length, [ $this, 'Sherlock' ] ); - - return $Buffer; - } - - 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; - } - } + +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 +{ + public function Close(): void + { + if ($this->Socket !== null) { + fclose($this->Socket); + + $this->Socket = null; + } + } + + 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); + } + + 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. + * + * @throws InvalidPacketException + * + * @return Buffer Buffer + */ + public function Read(int $Length = 1400): Buffer + { + $Buffer = new Buffer(); + $Buffer->Set(fread($this->Socket, $Length)); + + $this->ReadInternal($Buffer, $Length, [ $this, 'Sherlock' ]); + + return $Buffer; + } + + 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; + } +} diff --git a/SourceQuery/SourceQuery.php b/SourceQuery/SourceQuery.php index 74f7f57..f1a895b 100644 --- a/SourceQuery/SourceQuery.php +++ b/SourceQuery/SourceQuery.php @@ -1,570 +1,536 @@ Socket = $Socket ?: new Socket( ); - } - - public function __destruct( ) - { - $this->Disconnect( ); - } - - /** - * Opens connection to server - * - * @param string $Address Server ip - * @param int $Port Server port - * @param int $Timeout Timeout period - * @param int $Engine Engine the server runs on (goldsource, source) - * - * @throws InvalidArgumentException - * @throws SocketException - */ - public function Connect( string $Address, int $Port, int $Timeout = 3, int $Engine = self::SOURCE ) : void - { - $this->Disconnect( ); - - if( $Timeout < 0 ) - { - throw new InvalidArgumentException( 'Timeout must be a positive integer.', InvalidArgumentException::TIMEOUT_NOT_INTEGER ); - } - - $this->Socket->Open( $Address, $Port, $Timeout, $Engine ); - - $this->Connected = true; - } - - /** - * 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 - * - * @returns bool Previous value - */ - public function SetUseOldGetChallengeMethod( bool $Value ) : bool - { - $Previous = $this->UseOldGetChallengeMethod; - - $this->UseOldGetChallengeMethod = $Value === true; - - return $Previous; - } - - /** - * Closes all open connections - */ - public function Disconnect( ) : void - { - $this->Connected = false; - $this->Challenge = ''; - - $this->Socket->Close( ); - - if( $this->Rcon ) - { - $this->Rcon->Close( ); - - $this->Rcon = null; - } - } - - /** - * Sends ping packet to the server - * NOTE: This may not work on some games (TF2 for example) - * - * @throws InvalidPacketException - * @throws SocketException - * - * @return bool True on success, false on failure - */ - public function Ping( ) : bool - { - if( !$this->Connected ) - { - throw new SocketException( 'Not connected.', SocketException::NOT_CONNECTED ); - } - - $this->Socket->Write( self::A2A_PING ); - $Buffer = $this->Socket->Read( ); - - return $Buffer->GetByte( ) === self::A2A_ACK; - } - - /** - * Get server information - * - * @throws InvalidPacketException - * @throws SocketException - * - * @return array Returns an array with information on success - */ - public function GetInfo( ) : array - { - if( !$this->Connected ) - { - throw new SocketException( 'Not connected.', SocketException::NOT_CONNECTED ); - } - - if( $this->Challenge ) - { - $this->Socket->Write( self::A2S_INFO, "Source Engine Query\0" . $this->Challenge ); - } - else - { - $this->Socket->Write( self::A2S_INFO, "Source Engine Query\0" ); - } - - $Buffer = $this->Socket->Read( ); - $Type = $Buffer->GetByte( ); - $Server = []; - - if( $Type === self::S2C_CHALLENGE ) - { - $this->Challenge = $Buffer->Get( 4 ); - - $this->Socket->Write( self::A2S_INFO, "Source Engine Query\0" . $this->Challenge ); - $Buffer = $this->Socket->Read( ); - $Type = $Buffer->GetByte( ); - } - - // Old GoldSource protocol, HLTV still uses it - if( $Type === self::S2A_INFO_OLD && $this->Socket->Engine === self::GOLDSOURCE ) - { - /** - * If we try to read data again, and we get the result with type S2A_INFO (0x49) - * That means this server is running dproto, - * Because it sends answer for both protocols - */ - - $Server[ 'Address' ] = $Buffer->GetString( ); - $Server[ 'HostName' ] = $Buffer->GetString( ); - $Server[ 'Map' ] = $Buffer->GetString( ); - $Server[ 'ModDir' ] = $Buffer->GetString( ); - $Server[ 'ModDesc' ] = $Buffer->GetString( ); - $Server[ 'Players' ] = $Buffer->GetByte( ); - $Server[ 'MaxPlayers' ] = $Buffer->GetByte( ); - $Server[ 'Protocol' ] = $Buffer->GetByte( ); - $Server[ 'Dedicated' ] = chr( $Buffer->GetByte( ) ); - $Server[ 'Os' ] = chr( $Buffer->GetByte( ) ); - $Server[ 'Password' ] = $Buffer->GetByte( ) === 1; - $Server[ 'IsMod' ] = $Buffer->GetByte( ) === 1; - - if( $Server[ 'IsMod' ] ) - { - $Mod = []; - $Mod[ 'Url' ] = $Buffer->GetString( ); - $Mod[ 'Download' ] = $Buffer->GetString( ); - $Buffer->Get( 1 ); // NULL byte - $Mod[ 'Version' ] = $Buffer->GetLong( ); - $Mod[ 'Size' ] = $Buffer->GetLong( ); - $Mod[ 'ServerSide' ] = $Buffer->GetByte( ) === 1; - $Mod[ 'CustomDLL' ] = $Buffer->GetByte( ) === 1; - $Server[ 'Mod' ] = $Mod; - } - - $Server[ 'Secure' ] = $Buffer->GetByte( ) === 1; - $Server[ 'Bots' ] = $Buffer->GetByte( ); - - return $Server; - } - - if( $Type !== self::S2A_INFO_SRC ) - { - throw new InvalidPacketException( 'GetInfo: Packet header mismatch. (0x' . dechex( $Type ) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH ); - } - - $Server[ 'Protocol' ] = $Buffer->GetByte( ); - $Server[ 'HostName' ] = $Buffer->GetString( ); - $Server[ 'Map' ] = $Buffer->GetString( ); - $Server[ 'ModDir' ] = $Buffer->GetString( ); - $Server[ 'ModDesc' ] = $Buffer->GetString( ); - $Server[ 'AppID' ] = $Buffer->GetShort( ); - $Server[ 'Players' ] = $Buffer->GetByte( ); - $Server[ 'MaxPlayers' ] = $Buffer->GetByte( ); - $Server[ 'Bots' ] = $Buffer->GetByte( ); - $Server[ 'Dedicated' ] = chr( $Buffer->GetByte( ) ); - $Server[ 'Os' ] = chr( $Buffer->GetByte( ) ); - $Server[ 'Password' ] = $Buffer->GetByte( ) === 1; - $Server[ 'Secure' ] = $Buffer->GetByte( ) === 1; - - // The Ship (they violate query protocol spec by modifying the response) - if( $Server[ 'AppID' ] === 2400 ) - { - $Server[ 'GameMode' ] = $Buffer->GetByte( ); - $Server[ 'WitnessCount' ] = $Buffer->GetByte( ); - $Server[ 'WitnessTime' ] = $Buffer->GetByte( ); - } - - $Server[ 'Version' ] = $Buffer->GetString( ); - - // Extra Data Flags - if( $Buffer->Remaining( ) > 0 ) - { - $Server[ 'ExtraDataFlags' ] = $Flags = $Buffer->GetByte( ); - - // S2A_EXTRA_DATA_HAS_GAME_PORT - Next 2 bytes include the game port. - if( $Flags & 0x80 ) - { - $Server[ 'GamePort' ] = $Buffer->GetShort( ); - } - - // S2A_EXTRA_DATA_HAS_STEAMID - Next 8 bytes are the steamID - // Want to play around with this? - // You can use https://github.com/xPaw/SteamID.php - if( $Flags & 0x10 ) - { - $SteamIDLower = $Buffer->GetUnsignedLong( ); - $SteamIDInstance = $Buffer->GetUnsignedLong( ); // This gets shifted by 32 bits, which should be steamid instance - $SteamID = 0; - - if( PHP_INT_SIZE === 4 ) - { - if( extension_loaded( 'gmp' ) ) - { - $SteamIDLower = gmp_abs( $SteamIDLower ); - $SteamIDInstance = gmp_abs( $SteamIDInstance ); - $SteamID = gmp_strval( gmp_or( $SteamIDLower, gmp_mul( $SteamIDInstance, gmp_pow( 2, 32 ) ) ) ); - } - else - { - throw new \RuntimeException( 'Either 64-bit PHP installation or "gmp" module is required to correctly parse server\'s steamid.' ); - } - } - else - { - $SteamID = $SteamIDLower | ( $SteamIDInstance << 32 ); - } - - $Server[ 'SteamID' ] = $SteamID; - - unset( $SteamIDLower, $SteamIDInstance, $SteamID ); - } - - // S2A_EXTRA_DATA_HAS_SPECTATOR_DATA - Next 2 bytes include the spectator port, then the spectator server name. - if( $Flags & 0x40 ) - { - $Server[ 'SpecPort' ] = $Buffer->GetShort( ); - $Server[ 'SpecName' ] = $Buffer->GetString( ); - } - - // S2A_EXTRA_DATA_HAS_GAMETAG_DATA - Next bytes are the game tag string - if( $Flags & 0x20 ) - { - $Server[ 'GameTags' ] = $Buffer->GetString( ); - } - - // S2A_EXTRA_DATA_GAMEID - Next 8 bytes are the gameID of the server - if( $Flags & 0x01 ) - { - $Server[ 'GameID' ] = $Buffer->GetUnsignedLong( ) | ( $Buffer->GetUnsignedLong( ) << 32 ); - } - - if( $Buffer->Remaining( ) > 0 ) - { - throw new InvalidPacketException( 'GetInfo: unread data? ' . $Buffer->Remaining( ) . ' bytes remaining in the buffer. Please report it to the library developer.', - InvalidPacketException::BUFFER_NOT_EMPTY ); - } - } - - return $Server; - } - - /** - * Get players on the server - * - * @throws InvalidPacketException - * @throws SocketException - * - * @return array Returns an array with players on success - */ - public function GetPlayers( ) : array - { - if( !$this->Connected ) - { - throw new SocketException( 'Not connected.', SocketException::NOT_CONNECTED ); - } - - $this->GetChallenge( self::A2S_PLAYER, self::S2A_PLAYER ); - - $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 - // This violates the protocol spec, and they probably should fix it: https://developer.valvesoftware.com/wiki/Server_queries#Protocol - - $Type = $Buffer->GetByte( ); - - if( $Type !== self::S2A_PLAYER ) - { - throw new InvalidPacketException( 'GetPlayers: Packet header mismatch. (0x' . dechex( $Type ) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH ); - } - - $Players = []; - $Count = $Buffer->GetByte( ); - - while( $Count-- > 0 && $Buffer->Remaining( ) > 0 ) - { - $Player = []; - $Player[ 'Id' ] = $Buffer->GetByte( ); // PlayerID, is it just always 0? - $Player[ 'Name' ] = $Buffer->GetString( ); - $Player[ 'Frags' ] = $Buffer->GetLong( ); - $Player[ 'Time' ] = (int)$Buffer->GetFloat( ); - $Player[ 'TimeF' ] = gmdate( ( $Player[ 'Time' ] > 3600 ? 'H:i:s' : 'i:s' ), $Player[ 'Time' ] ); - - $Players[ ] = $Player; - } - - return $Players; - } - - /** - * Get rules (cvars) from the server - * - * @throws InvalidPacketException - * @throws SocketException - * - * @return array Returns an array with rules on success - */ - public function GetRules( ) : array - { - if( !$this->Connected ) - { - throw new SocketException( 'Not connected.', SocketException::NOT_CONNECTED ); - } - - $this->GetChallenge( self::A2S_RULES, self::S2A_RULES ); - - $this->Socket->Write( self::A2S_RULES, $this->Challenge ); - $Buffer = $this->Socket->Read( ); - - $Type = $Buffer->GetByte( ); - - if( $Type !== self::S2A_RULES ) - { - throw new InvalidPacketException( 'GetRules: Packet header mismatch. (0x' . dechex( $Type ) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH ); - } - - $Rules = []; - $Count = $Buffer->GetShort( ); - - while( $Count-- > 0 && $Buffer->Remaining( ) > 0 ) - { - $Rule = $Buffer->GetString( ); - $Value = $Buffer->GetString( ); - - if( !empty( $Rule ) ) - { - $Rules[ $Rule ] = $Value; - } - } - - return $Rules; - } - - /** - * Get challenge (used for players/rules packets) - * - * @throws InvalidPacketException - */ - private function GetChallenge( int $Header, int $ExpectedResult ) : void - { - if( $this->Challenge ) - { - return; - } - - if( $this->UseOldGetChallengeMethod ) - { - $Header = self::A2S_SERVERQUERY_GETCHALLENGE; - } - - $this->Socket->Write( $Header, "\xFF\xFF\xFF\xFF" ); - $Buffer = $this->Socket->Read( ); - - $Type = $Buffer->GetByte( ); - - switch( $Type ) - { - case self::S2C_CHALLENGE: - { - $this->Challenge = $Buffer->Get( 4 ); - - return; - } - case $ExpectedResult: - { - // Goldsource (HLTV) - - return; - } - case 0: - { - throw new InvalidPacketException( 'GetChallenge: Failed to get challenge.' ); - } - default: - { - throw new InvalidPacketException( 'GetChallenge: Packet header mismatch. (0x' . dechex( $Type ) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH ); - } - } - } - - /** - * Sets rcon password, for future use in Rcon() - * - * @param string $Password Rcon Password - * - * @throws AuthenticationException - * @throws InvalidPacketException - * @throws SocketException - */ - public function SetRconPassword( string $Password ) : void - { - if( !$this->Connected ) - { - throw new SocketException( 'Not connected.', SocketException::NOT_CONNECTED ); - } - - switch( $this->Socket->Engine ) - { - case SourceQuery::GOLDSOURCE: - { - $this->Rcon = new GoldSourceRcon( $this->Socket ); - - break; - } - case SourceQuery::SOURCE: - { - $this->Rcon = new SourceRcon( $this->Socket ); - - break; - } - default: - { - throw new SocketException( 'Unknown engine.', SocketException::INVALID_ENGINE ); - } - } - - $this->Rcon->Open( ); - $this->Rcon->Authorize( $Password ); - } - - /** - * Sends a command to the server for execution. - * - * @param string $Command Command to execute - * - * @throws AuthenticationException - * @throws InvalidPacketException - * @throws SocketException - * - * @return string Answer from server in string - */ - public function Rcon( string $Command ) : string - { - if( !$this->Connected ) - { - throw new SocketException( 'Not connected.', SocketException::NOT_CONNECTED ); - } - - if( $this->Rcon === null ) - { - throw new SocketException( 'You must set a RCON password before trying to execute a RCON command.', SocketException::NOT_CONNECTED ); - } - - return $this->Rcon->Command( $Command ); - } - } + +declare(strict_types=1); + +/** + * This class provides the public interface to the PHP-Source-Query library. + * + * @author Pavel Djundik + * + * @link https://xpaw.me + * @link https://github.com/xPaw/PHP-Source-Query + * + * @license GNU Lesser General Public License, version 2.1 + */ + +namespace xPaw\SourceQuery; + +use xPaw\SourceQuery\Exception\AuthenticationException; +use xPaw\SourceQuery\Exception\InvalidArgumentException; +use xPaw\SourceQuery\Exception\InvalidPacketException; +use xPaw\SourceQuery\Exception\SocketException; + +/** + * Class SourceQuery + * + * @package xPaw\SourceQuery + * + * @uses AuthenticationException + * @uses InvalidArgumentException + * @uses InvalidPacketException + * @uses SocketException + */ +final class SourceQuery +{ + /** + * Engines + */ + public const GOLDSOURCE = 0; + public const SOURCE = 1; + + /** + * Packets sent + */ + private const A2A_PING = 0x69; + private const A2S_INFO = 0x54; + private const A2S_PLAYER = 0x55; + private const A2S_RULES = 0x56; + private const A2S_SERVERQUERY_GETCHALLENGE = 0x57; + + /** + * Packets received + */ + private const A2A_ACK = 0x6A; + private const S2C_CHALLENGE = 0x41; + private const S2A_INFO_SRC = 0x49; + private const S2A_INFO_OLD = 0x6D; // Old GoldSource, HLTV uses it (actually called S2A_INFO_DETAILED) + private const S2A_PLAYER = 0x44; + private const S2A_RULES = 0x45; + public const S2A_RCON = 0x6C; + + /** + * Source rcon sent + */ + public const SERVERDATA_REQUESTVALUE = 0; + public const SERVERDATA_EXECCOMMAND = 2; + public const SERVERDATA_AUTH = 3; + + /** + * Source rcon received + */ + public const SERVERDATA_RESPONSE_VALUE = 0; + public const SERVERDATA_AUTH_RESPONSE = 2; + + /** + * Points to rcon class + * + * @var SourceRcon|GoldSourceRcon|null + */ + private $Rcon; + + /** + * Points to socket class + */ + private BaseSocket $Socket; + + /** + * True if connection is open, false if not + */ + private bool $Connected = false; + + /** + * Contains challenge + */ + private string $Challenge = ''; + + /** + * Use old method for getting challenge number + */ + private bool $UseOldGetChallengeMethod = false; + + public function __construct(BaseSocket $Socket = null) + { + $this->Socket = $Socket ?: new Socket(); + } + + public function __destruct() + { + $this->Disconnect(); + } + + /** + * Opens connection to server + * + * @param string $Address Server ip + * @param int $Port Server port + * @param int $Timeout Timeout period + * @param int $Engine Engine the server runs on (goldsource, source) + * + * @throws InvalidArgumentException + * @throws SocketException + */ + public function Connect(string $Address, int $Port, int $Timeout = 3, int $Engine = self::SOURCE): void + { + $this->Disconnect(); + + if ($Timeout < 0) { + throw new InvalidArgumentException('Timeout must be a positive integer.', InvalidArgumentException::TIMEOUT_NOT_INTEGER); + } + + $this->Socket->Open($Address, $Port, $Timeout, $Engine); + + $this->Connected = true; + } + + /** + * 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 + * + * @returns bool Previous value + */ + public function SetUseOldGetChallengeMethod(bool $Value): bool + { + $Previous = $this->UseOldGetChallengeMethod; + + $this->UseOldGetChallengeMethod = $Value === true; + + return $Previous; + } + + /** + * Closes all open connections + */ + public function Disconnect(): void + { + $this->Connected = false; + $this->Challenge = ''; + + $this->Socket->Close(); + + if ($this->Rcon) { + $this->Rcon->Close(); + + $this->Rcon = null; + } + } + + /** + * Sends ping packet to the server + * NOTE: This may not work on some games (TF2 for example) + * + * @throws InvalidPacketException + * @throws SocketException + * + * @return bool True on success, false on failure + */ + public function Ping(): bool + { + if (!$this->Connected) { + throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); + } + + $this->Socket->Write(self::A2A_PING); + $Buffer = $this->Socket->Read(); + + return $Buffer->GetByte() === self::A2A_ACK; + } + + /** + * Get server information + * + * @throws InvalidPacketException + * @throws SocketException + * + * @return array Returns an array with information on success + */ + public function GetInfo(): array + { + if (!$this->Connected) { + throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); + } + + if ($this->Challenge) { + $this->Socket->Write(self::A2S_INFO, "Source Engine Query\0" . $this->Challenge); + } else { + $this->Socket->Write(self::A2S_INFO, "Source Engine Query\0"); + } + + $Buffer = $this->Socket->Read(); + $Type = $Buffer->GetByte(); + $Server = []; + + if ($Type === self::S2C_CHALLENGE) { + $this->Challenge = $Buffer->Get(4); + + $this->Socket->Write(self::A2S_INFO, "Source Engine Query\0" . $this->Challenge); + $Buffer = $this->Socket->Read(); + $Type = $Buffer->GetByte(); + } + + // Old GoldSource protocol, HLTV still uses it + if ($Type === self::S2A_INFO_OLD && $this->Socket->Engine === self::GOLDSOURCE) { + /** + * If we try to read data again, and we get the result with type S2A_INFO (0x49) + * That means this server is running dproto, + * Because it sends answer for both protocols + */ + + $Server[ 'Address' ] = $Buffer->GetString(); + $Server[ 'HostName' ] = $Buffer->GetString(); + $Server[ 'Map' ] = $Buffer->GetString(); + $Server[ 'ModDir' ] = $Buffer->GetString(); + $Server[ 'ModDesc' ] = $Buffer->GetString(); + $Server[ 'Players' ] = $Buffer->GetByte(); + $Server[ 'MaxPlayers' ] = $Buffer->GetByte(); + $Server[ 'Protocol' ] = $Buffer->GetByte(); + $Server[ 'Dedicated' ] = chr($Buffer->GetByte()); + $Server[ 'Os' ] = chr($Buffer->GetByte()); + $Server[ 'Password' ] = $Buffer->GetByte() === 1; + $Server[ 'IsMod' ] = $Buffer->GetByte() === 1; + + if ($Server[ 'IsMod' ]) { + $Mod = []; + $Mod[ 'Url' ] = $Buffer->GetString(); + $Mod[ 'Download' ] = $Buffer->GetString(); + $Buffer->Get(1); // NULL byte + $Mod[ 'Version' ] = $Buffer->GetLong(); + $Mod[ 'Size' ] = $Buffer->GetLong(); + $Mod[ 'ServerSide' ] = $Buffer->GetByte() === 1; + $Mod[ 'CustomDLL' ] = $Buffer->GetByte() === 1; + $Server[ 'Mod' ] = $Mod; + } + + $Server[ 'Secure' ] = $Buffer->GetByte() === 1; + $Server[ 'Bots' ] = $Buffer->GetByte(); + + return $Server; + } + + if ($Type !== self::S2A_INFO_SRC) { + throw new InvalidPacketException('GetInfo: Packet header mismatch. (0x' . dechex($Type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH); + } + + $Server[ 'Protocol' ] = $Buffer->GetByte(); + $Server[ 'HostName' ] = $Buffer->GetString(); + $Server[ 'Map' ] = $Buffer->GetString(); + $Server[ 'ModDir' ] = $Buffer->GetString(); + $Server[ 'ModDesc' ] = $Buffer->GetString(); + $Server[ 'AppID' ] = $Buffer->GetShort(); + $Server[ 'Players' ] = $Buffer->GetByte(); + $Server[ 'MaxPlayers' ] = $Buffer->GetByte(); + $Server[ 'Bots' ] = $Buffer->GetByte(); + $Server[ 'Dedicated' ] = chr($Buffer->GetByte()); + $Server[ 'Os' ] = chr($Buffer->GetByte()); + $Server[ 'Password' ] = $Buffer->GetByte() === 1; + $Server[ 'Secure' ] = $Buffer->GetByte() === 1; + + // The Ship (they violate query protocol spec by modifying the response) + if ($Server[ 'AppID' ] === 2400) { + $Server[ 'GameMode' ] = $Buffer->GetByte(); + $Server[ 'WitnessCount' ] = $Buffer->GetByte(); + $Server[ 'WitnessTime' ] = $Buffer->GetByte(); + } + + $Server[ 'Version' ] = $Buffer->GetString(); + + // Extra Data Flags + if ($Buffer->Remaining() > 0) { + $Server[ 'ExtraDataFlags' ] = $Flags = $Buffer->GetByte(); + + // S2A_EXTRA_DATA_HAS_GAME_PORT - Next 2 bytes include the game port. + if ($Flags & 0x80) { + $Server[ 'GamePort' ] = $Buffer->GetShort(); + } + + // S2A_EXTRA_DATA_HAS_STEAMID - Next 8 bytes are the steamID + // Want to play around with this? + // You can use https://github.com/xPaw/SteamID.php + if ($Flags & 0x10) { + $SteamIDLower = $Buffer->GetUnsignedLong(); + $SteamIDInstance = $Buffer->GetUnsignedLong(); // This gets shifted by 32 bits, which should be steamid instance + $SteamID = 0; + + if (PHP_INT_SIZE === 4) { + if (extension_loaded('gmp')) { + $SteamIDLower = gmp_abs($SteamIDLower); + $SteamIDInstance = gmp_abs($SteamIDInstance); + $SteamID = gmp_strval(gmp_or($SteamIDLower, gmp_mul($SteamIDInstance, gmp_pow(2, 32)))); + } else { + throw new \RuntimeException('Either 64-bit PHP installation or "gmp" module is required to correctly parse server\'s steamid.'); + } + } else { + $SteamID = $SteamIDLower | ($SteamIDInstance << 32); + } + + $Server[ 'SteamID' ] = $SteamID; + + unset($SteamIDLower, $SteamIDInstance, $SteamID); + } + + // S2A_EXTRA_DATA_HAS_SPECTATOR_DATA - Next 2 bytes include the spectator port, then the spectator server name. + if ($Flags & 0x40) { + $Server[ 'SpecPort' ] = $Buffer->GetShort(); + $Server[ 'SpecName' ] = $Buffer->GetString(); + } + + // S2A_EXTRA_DATA_HAS_GAMETAG_DATA - Next bytes are the game tag string + if ($Flags & 0x20) { + $Server[ 'GameTags' ] = $Buffer->GetString(); + } + + // S2A_EXTRA_DATA_GAMEID - Next 8 bytes are the gameID of the server + if ($Flags & 0x01) { + $Server[ 'GameID' ] = $Buffer->GetUnsignedLong() | ($Buffer->GetUnsignedLong() << 32); + } + + if ($Buffer->Remaining() > 0) { + throw new InvalidPacketException( + 'GetInfo: unread data? ' . $Buffer->Remaining() . ' bytes remaining in the buffer. Please report it to the library developer.', + InvalidPacketException::BUFFER_NOT_EMPTY + ); + } + } + + return $Server; + } + + /** + * Get players on the server + * + * @throws InvalidPacketException + * @throws SocketException + * + * @return array Returns an array with players on success + */ + public function GetPlayers(): array + { + if (!$this->Connected) { + throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); + } + + $this->GetChallenge(self::A2S_PLAYER, self::S2A_PLAYER); + + $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 + // This violates the protocol spec, and they probably should fix it: https://developer.valvesoftware.com/wiki/Server_queries#Protocol + + $Type = $Buffer->GetByte(); + + if ($Type !== self::S2A_PLAYER) { + throw new InvalidPacketException('GetPlayers: Packet header mismatch. (0x' . dechex($Type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH); + } + + $Players = []; + $Count = $Buffer->GetByte(); + + while ($Count-- > 0 && $Buffer->Remaining() > 0) { + $Player = []; + $Player[ 'Id' ] = $Buffer->GetByte(); // PlayerID, is it just always 0? + $Player[ 'Name' ] = $Buffer->GetString(); + $Player[ 'Frags' ] = $Buffer->GetLong(); + $Player[ 'Time' ] = (int)$Buffer->GetFloat(); + $Player[ 'TimeF' ] = gmdate(($Player[ 'Time' ] > 3600 ? 'H:i:s' : 'i:s'), $Player[ 'Time' ]); + + $Players[ ] = $Player; + } + + return $Players; + } + + /** + * Get rules (cvars) from the server + * + * @throws InvalidPacketException + * @throws SocketException + * + * @return array Returns an array with rules on success + */ + public function GetRules(): array + { + if (!$this->Connected) { + throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); + } + + $this->GetChallenge(self::A2S_RULES, self::S2A_RULES); + + $this->Socket->Write(self::A2S_RULES, $this->Challenge); + $Buffer = $this->Socket->Read(); + + $Type = $Buffer->GetByte(); + + if ($Type !== self::S2A_RULES) { + throw new InvalidPacketException('GetRules: Packet header mismatch. (0x' . dechex($Type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH); + } + + $Rules = []; + $Count = $Buffer->GetShort(); + + while ($Count-- > 0 && $Buffer->Remaining() > 0) { + $Rule = $Buffer->GetString(); + $Value = $Buffer->GetString(); + + if (!empty($Rule)) { + $Rules[ $Rule ] = $Value; + } + } + + return $Rules; + } + + /** + * Get challenge (used for players/rules packets) + * + * @throws InvalidPacketException + */ + private function GetChallenge(int $Header, int $ExpectedResult): void + { + if ($this->Challenge) { + return; + } + + if ($this->UseOldGetChallengeMethod) { + $Header = self::A2S_SERVERQUERY_GETCHALLENGE; + } + + $this->Socket->Write($Header, "\xFF\xFF\xFF\xFF"); + $Buffer = $this->Socket->Read(); + + $Type = $Buffer->GetByte(); + + switch ($Type) { + case self::S2C_CHALLENGE: + { + $this->Challenge = $Buffer->Get(4); + + return; + } + case $ExpectedResult: + { + // Goldsource (HLTV) + + return; + } + case 0: + { + throw new InvalidPacketException('GetChallenge: Failed to get challenge.'); + } + default: + { + throw new InvalidPacketException('GetChallenge: Packet header mismatch. (0x' . dechex($Type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH); + } + } + } + + /** + * Sets rcon password, for future use in Rcon() + * + * @param string $Password Rcon Password + * + * @throws AuthenticationException + * @throws InvalidPacketException + * @throws SocketException + */ + public function SetRconPassword(string $Password): void + { + if (!$this->Connected) { + throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); + } + + switch ($this->Socket->Engine) { + case SourceQuery::GOLDSOURCE: + { + $this->Rcon = new GoldSourceRcon($this->Socket); + + break; + } + case SourceQuery::SOURCE: + { + $this->Rcon = new SourceRcon($this->Socket); + + break; + } + default: + { + throw new SocketException('Unknown engine.', SocketException::INVALID_ENGINE); + } + } + + $this->Rcon->Open(); + $this->Rcon->Authorize($Password); + } + + /** + * Sends a command to the server for execution. + * + * @param string $Command Command to execute + * + * @throws AuthenticationException + * @throws InvalidPacketException + * @throws SocketException + * + * @return string Answer from server in string + */ + public function Rcon(string $Command): string + { + if (!$this->Connected) { + throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); + } + + if ($this->Rcon === null) { + throw new SocketException('You must set a RCON password before trying to execute a RCON command.', SocketException::NOT_CONNECTED); + } + + return $this->Rcon->Command($Command); + } +} diff --git a/SourceQuery/SourceRcon.php b/SourceQuery/SourceRcon.php index 56919e0..88625b9 100644 --- a/SourceQuery/SourceRcon.php +++ b/SourceQuery/SourceRcon.php @@ -1,199 +1,186 @@ Socket = $Socket; - } - - public function Close( ) : void - { - if( $this->RconSocket ) - { - fclose( $this->RconSocket ); - - $this->RconSocket = null; - } - - $this->RconRequestId = 0; - } - - 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 ); - } - } - - 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 ); - } - - 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; - } - - 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 ); - } - else if( $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" ); - } - - 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 ); - } - } - } + +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; + private int $RconRequestId = 0; + + public function __construct(BaseSocket $Socket) + { + $this->Socket = $Socket; + } + + public function Close(): void + { + if ($this->RconSocket) { + fclose($this->RconSocket); + + $this->RconSocket = null; + } + + $this->RconRequestId = 0; + } + + 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); + } + } + + 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); + } + + 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; + } + + 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"); + } + + 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); + } + } +} diff --git a/SourceQuery/bootstrap.php b/SourceQuery/bootstrap.php index 9cb6571..c11049e 100644 --- a/SourceQuery/bootstrap.php +++ b/SourceQuery/bootstrap.php @@ -1,27 +1,30 @@ */ - private \SplQueue $PacketQueue; - - public function __construct( ) - { - /** @var \SplQueue */ - $this->PacketQueue = new \SplQueue(); - $this->PacketQueue->setIteratorMode( \SplDoublyLinkedList::IT_MODE_DELETE ); - - } - - public function Queue( string $Data ) : void - { - $this->PacketQueue->push( $Data ); - } - - public function Close( ) : void - { - // - } - - public function Open( string $Address, int $Port, int $Timeout, int $Engine ) : void - { - $this->Timeout = $Timeout; - $this->Engine = $Engine; - $this->Port = $Port; - $this->Address = $Address; - } - - public function Write( int $Header, string $String = '' ) : bool - { - return true; - } - - public function Read( int $Length = 1400 ) : Buffer - { - $Buffer = new Buffer( ); - $Buffer->Set( $this->PacketQueue->shift() ); - - $this->ReadInternal( $Buffer, $Length, [ $this, 'Sherlock' ] ); - - return $Buffer; - } - - public function Sherlock( Buffer $Buffer, int $Length ) : bool - { - if( $this->PacketQueue->isEmpty() ) - { - return false; - } - - $Buffer->Set( $this->PacketQueue->shift() ); - - return $Buffer->GetLong( ) === -2; - } - } - - class Tests extends \PHPUnit\Framework\TestCase - { - private TestableSocket $Socket; - private SourceQuery $SourceQuery; - - public function setUp() : void - { - $this->Socket = new TestableSocket(); - $this->SourceQuery = new SourceQuery( $this->Socket ); - $this->SourceQuery->Connect( '', 2 ); - } - - public function tearDown() : void - { - $this->SourceQuery->Disconnect(); - - unset( $this->Socket, $this->SourceQuery ); - } - - public function testInvalidTimeout() : void - { - $this->expectException( xPaw\SourceQuery\Exception\InvalidArgumentException::class ); - $SourceQuery = new SourceQuery( ); - $SourceQuery->Connect( '', 2, -1 ); - } - - public function testNotConnectedGetInfo() : void - { - $this->expectException( xPaw\SourceQuery\Exception\SocketException::class ); - $this->SourceQuery->Disconnect(); - $this->SourceQuery->GetInfo(); - } - - public function testNotConnectedPing() : void - { - $this->expectException( xPaw\SourceQuery\Exception\SocketException::class ); - $this->SourceQuery->Disconnect(); - $this->SourceQuery->Ping(); - } - - public function testNotConnectedGetPlayers() : void - { - $this->expectException( xPaw\SourceQuery\Exception\SocketException::class ); - $this->SourceQuery->Disconnect(); - $this->SourceQuery->GetPlayers(); - } - - /** - * @expectedException xPaw\SourceQuery\Exception\SocketException - */ - public function testNotConnectedGetRules() : void - { - $this->expectException( xPaw\SourceQuery\Exception\SocketException::class ); - $this->SourceQuery->Disconnect(); - $this->SourceQuery->GetRules(); - } - - public function testNotConnectedSetRconPassword() : void - { - $this->expectException( xPaw\SourceQuery\Exception\SocketException::class ); - $this->SourceQuery->Disconnect(); - $this->SourceQuery->SetRconPassword('a'); - } - - public function testNotConnectedRcon() : void - { - $this->expectException( xPaw\SourceQuery\Exception\SocketException::class ); - $this->SourceQuery->Disconnect(); - $this->SourceQuery->Rcon('a'); - } - - public function testRconWithoutPassword() : void - { - $this->expectException( xPaw\SourceQuery\Exception\SocketException::class ); - $this->SourceQuery->Rcon('a'); - } - - /** - * @dataProvider InfoProvider - */ - public function testGetInfo( string $RawInput, array $ExpectedOutput ) : void - { - if( isset( $ExpectedOutput[ 'IsMod' ] ) ) - { - $this->Socket->Engine = SourceQuery::GOLDSOURCE; - } - - $this->Socket->Queue( $RawInput ); - - $RealOutput = $this->SourceQuery->GetInfo(); - - $this->assertEquals( $ExpectedOutput, $RealOutput ); - } - - public function InfoProvider() : array - { - $DataProvider = []; - - $Files = glob( __DIR__ . '/Info/*.raw', GLOB_ERR ); - - foreach( $Files as $File ) - { - $DataProvider[] = - [ - hex2bin( trim( file_get_contents( $File ) ) ), - json_decode( file_get_contents( str_replace( '.raw', '.json', $File ) ), true ) - ]; - } - - return $DataProvider; - } - - /** - * @dataProvider BadPacketProvider - */ - public function testBadGetInfo( string $Data ) : void - { - $this->expectException( xPaw\SourceQuery\Exception\InvalidPacketException::class ); - $this->Socket->Queue( $Data ); - - $this->SourceQuery->GetInfo(); - } - - /** - * @dataProvider BadPacketProvider - */ - public function testBadGetChallengeViaPlayers( string $Data ) : void - { - $this->expectException( xPaw\SourceQuery\Exception\InvalidPacketException::class ); - $this->Socket->Queue( $Data ); - - $this->SourceQuery->GetPlayers(); - } - - /** - * @dataProvider BadPacketProvider - */ - public function testBadGetPlayersAfterCorrectChallenge( string $Data ) : void - { - $this->expectException( xPaw\SourceQuery\Exception\InvalidPacketException::class ); - $this->Socket->Queue( "\xFF\xFF\xFF\xFF\x41\x11\x11\x11\x11" ); - $this->Socket->Queue( $Data ); - - $this->SourceQuery->GetPlayers(); - } - - /** - * @dataProvider BadPacketProvider - */ - public function testBadGetRulesAfterCorrectChallenge( string $Data ) : void - { - $this->expectException( xPaw\SourceQuery\Exception\InvalidPacketException::class ); - $this->Socket->Queue( "\xFF\xFF\xFF\xFF\x41\x11\x11\x11\x11" ); - $this->Socket->Queue( $Data ); - - $this->SourceQuery->GetRules(); - } - - public function BadPacketProvider( ) : array - { - return - [ - [ "" ], - [ "\xff\xff\xff\xff" ], // No type - [ "\xff\xff\xff\xff\x49" ], // Correct type, but no data after - [ "\xff\xff\xff\xff\x6D" ], // Old info packet, but tests are done for source - [ "\xff\xff\xff\xff\x11" ], // Wrong type - [ "\x11\x11\x11\x11" ], // Wrong header - [ "\xff" ], // Should be 4 bytes, but it's 1 - ]; - } - - public function testGetChallengeTwice( ) : void - { - $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->assertEquals( [ 'ayy' => 'lmao' ], $this->SourceQuery->GetRules() ); - - $this->Socket->Queue( "\xFF\xFF\xFF\xFF\x45\x01\x00wow\x00much\x00" ); - $this->assertEquals( [ 'wow' => 'much' ], $this->SourceQuery->GetRules() ); - } - - /** - * @dataProvider RulesProvider - * @param array $RawInput - */ - public function testGetRules( array $RawInput, array $ExpectedOutput ) : void - { - $this->Socket->Queue( hex2bin( "ffffffff4104fce20e" ) ); // Challenge - - foreach( $RawInput as $Packet ) - { - $this->Socket->Queue( hex2bin( $Packet ) ); - } - - $RealOutput = $this->SourceQuery->GetRules(); - - $this->assertEquals( $ExpectedOutput, $RealOutput ); - } - - public function RulesProvider() : array - { - $DataProvider = []; - - $Files = glob( __DIR__ . '/Rules/*.raw', GLOB_ERR ); - - foreach( $Files as $File ) - { - $DataProvider[] = - [ - file( $File, FILE_SKIP_EMPTY_LINES | FILE_IGNORE_NEW_LINES ), - json_decode( file_get_contents( str_replace( '.raw', '.json', $File ) ), true ) - ]; - } - - return $DataProvider; - } - - /** - * @dataProvider PlayersProvider - * @param array $RawInput - */ - public function testGetPlayers( array $RawInput, array $ExpectedOutput ) : void - { - $this->Socket->Queue( hex2bin( "ffffffff4104fce20e" ) ); // Challenge - - foreach( $RawInput as $Packet ) - { - $this->Socket->Queue( hex2bin( $Packet ) ); - } - - $RealOutput = $this->SourceQuery->GetPlayers(); - - $this->assertEquals( $ExpectedOutput, $RealOutput ); - } - - public function PlayersProvider() : array - { - $DataProvider = []; - - $Files = glob( __DIR__ . '/Players/*.raw', GLOB_ERR ); - - foreach( $Files as $File ) - { - $DataProvider[] = - [ - file( $File, FILE_SKIP_EMPTY_LINES | FILE_IGNORE_NEW_LINES ), - json_decode( file_get_contents( str_replace( '.raw', '.json', $File ) ), true ) - ]; - } - - return $DataProvider; - } - - public function testPing() : void - { - $this->Socket->Queue( "\xFF\xFF\xFF\xFF\x6A\x00"); - $this->assertTrue( $this->SourceQuery->Ping() ); - - $this->Socket->Queue( "\xFF\xFF\xFF\xFF\xEE"); - $this->assertFalse( $this->SourceQuery->Ping() ); - } - } + +declare(strict_types=1); + +use PHPUnit\Framework\TestCase; +use xPaw\SourceQuery\BaseSocket; +use xPaw\SourceQuery\SourceQuery; +use xPaw\SourceQuery\Buffer; + +final class TestableSocket extends BaseSocket +{ + /** @var \SplQueue */ + private \SplQueue $PacketQueue; + + public function __construct() + { + $this->PacketQueue = new \SplQueue(); + $this->PacketQueue->setIteratorMode(\SplDoublyLinkedList::IT_MODE_DELETE); + } + + public function Queue(string $Data): void + { + $this->PacketQueue->push($Data); + } + + public function Close(): void + { + // + } + + public function Open(string $Address, int $Port, int $Timeout, int $Engine): void + { + $this->Timeout = $Timeout; + $this->Engine = $Engine; + $this->Port = $Port; + $this->Address = $Address; + } + + public function Write(int $Header, string $String = ''): bool + { + return true; + } + + public function Read(int $Length = 1400): Buffer + { + $Buffer = new Buffer(); + $Buffer->Set($this->PacketQueue->shift()); + + $this->ReadInternal($Buffer, $Length, [ $this, 'Sherlock' ]); + + return $Buffer; + } + + public function Sherlock(Buffer $Buffer, int $Length): bool + { + if ($this->PacketQueue->isEmpty()) { + return false; + } + + $Buffer->Set($this->PacketQueue->shift()); + + return $Buffer->GetLong() === -2; + } +} + +final class Tests extends TestCase +{ + private TestableSocket $Socket; + private SourceQuery $SourceQuery; + + public function setUp(): void + { + $this->Socket = new TestableSocket(); + $this->SourceQuery = new SourceQuery($this->Socket); + $this->SourceQuery->Connect('', 2); + } + + public function tearDown(): void + { + $this->SourceQuery->Disconnect(); + + unset($this->Socket, $this->SourceQuery); + } + + public function testInvalidTimeout(): void + { + $this->expectException(xPaw\SourceQuery\Exception\InvalidArgumentException::class); + $SourceQuery = new SourceQuery(); + $SourceQuery->Connect('', 2, -1); + } + + public function testNotConnectedGetInfo(): void + { + $this->expectException(xPaw\SourceQuery\Exception\SocketException::class); + $this->SourceQuery->Disconnect(); + $this->SourceQuery->GetInfo(); + } + + public function testNotConnectedPing(): void + { + $this->expectException(xPaw\SourceQuery\Exception\SocketException::class); + $this->SourceQuery->Disconnect(); + $this->SourceQuery->Ping(); + } + + public function testNotConnectedGetPlayers(): void + { + $this->expectException(xPaw\SourceQuery\Exception\SocketException::class); + $this->SourceQuery->Disconnect(); + $this->SourceQuery->GetPlayers(); + } + + /** + * @expectedException xPaw\SourceQuery\Exception\SocketException + */ + public function testNotConnectedGetRules(): void + { + $this->expectException(xPaw\SourceQuery\Exception\SocketException::class); + $this->SourceQuery->Disconnect(); + $this->SourceQuery->GetRules(); + } + + public function testNotConnectedSetRconPassword(): void + { + $this->expectException(xPaw\SourceQuery\Exception\SocketException::class); + $this->SourceQuery->Disconnect(); + $this->SourceQuery->SetRconPassword('a'); + } + + public function testNotConnectedRcon(): void + { + $this->expectException(xPaw\SourceQuery\Exception\SocketException::class); + $this->SourceQuery->Disconnect(); + $this->SourceQuery->Rcon('a'); + } + + public function testRconWithoutPassword(): void + { + $this->expectException(xPaw\SourceQuery\Exception\SocketException::class); + $this->SourceQuery->Rcon('a'); + } + + /** + * @dataProvider InfoProvider + */ + public function testGetInfo(string $RawInput, array $ExpectedOutput): void + { + if (isset($ExpectedOutput[ 'IsMod' ])) { + $this->Socket->Engine = SourceQuery::GOLDSOURCE; + } + + $this->Socket->Queue($RawInput); + + $RealOutput = $this->SourceQuery->GetInfo(); + + self::assertEquals($ExpectedOutput, $RealOutput); + } + + public function InfoProvider(): array + { + $DataProvider = []; + + $Files = glob(__DIR__ . '/Info/*.raw', GLOB_ERR); + + foreach ($Files as $File) { + $DataProvider[] = + [ + hex2bin(trim(file_get_contents($File))), + json_decode(file_get_contents(str_replace('.raw', '.json', $File)), true) + ]; + } + + return $DataProvider; + } + + /** + * @dataProvider BadPacketProvider + */ + public function testBadGetInfo(string $Data): void + { + $this->expectException(xPaw\SourceQuery\Exception\InvalidPacketException::class); + $this->Socket->Queue($Data); + + $this->SourceQuery->GetInfo(); + } + + /** + * @dataProvider BadPacketProvider + */ + public function testBadGetChallengeViaPlayers(string $Data): void + { + $this->expectException(xPaw\SourceQuery\Exception\InvalidPacketException::class); + $this->Socket->Queue($Data); + + $this->SourceQuery->GetPlayers(); + } + + /** + * @dataProvider BadPacketProvider + */ + public function testBadGetPlayersAfterCorrectChallenge(string $Data): void + { + $this->expectException(xPaw\SourceQuery\Exception\InvalidPacketException::class); + $this->Socket->Queue("\xFF\xFF\xFF\xFF\x41\x11\x11\x11\x11"); + $this->Socket->Queue($Data); + + $this->SourceQuery->GetPlayers(); + } + + /** + * @dataProvider BadPacketProvider + */ + public function testBadGetRulesAfterCorrectChallenge(string $Data): void + { + $this->expectException(xPaw\SourceQuery\Exception\InvalidPacketException::class); + $this->Socket->Queue("\xFF\xFF\xFF\xFF\x41\x11\x11\x11\x11"); + $this->Socket->Queue($Data); + + $this->SourceQuery->GetRules(); + } + + public function BadPacketProvider(): array + { + return + [ + [ "" ], + [ "\xff\xff\xff\xff" ], // No type + [ "\xff\xff\xff\xff\x49" ], // Correct type, but no data after + [ "\xff\xff\xff\xff\x6D" ], // Old info packet, but tests are done for source + [ "\xff\xff\xff\xff\x11" ], // Wrong type + [ "\x11\x11\x11\x11" ], // Wrong header + [ "\xff" ], // Should be 4 bytes, but it's 1 + ]; + } + + public function testGetChallengeTwice(): void + { + $this->Socket->Queue("\xFF\xFF\xFF\xFF\x41\x11\x11\x11\x11"); + $this->Socket->Queue("\xFF\xFF\xFF\xFF\x45\x01\x00ayy\x00lmao\x00"); + self::assertEquals([ 'ayy' => 'lmao' ], $this->SourceQuery->GetRules()); + + $this->Socket->Queue("\xFF\xFF\xFF\xFF\x45\x01\x00wow\x00much\x00"); + self::assertEquals([ 'wow' => 'much' ], $this->SourceQuery->GetRules()); + } + + /** + * @dataProvider RulesProvider + * @param array $RawInput + */ + public function testGetRules(array $RawInput, array $ExpectedOutput): void + { + $this->Socket->Queue(hex2bin("ffffffff4104fce20e")); // Challenge + + foreach ($RawInput as $Packet) { + $this->Socket->Queue(hex2bin($Packet)); + } + + $RealOutput = $this->SourceQuery->GetRules(); + + self::assertEquals($ExpectedOutput, $RealOutput); + } + + public function RulesProvider(): array + { + $DataProvider = []; + + $Files = glob(__DIR__ . '/Rules/*.raw', GLOB_ERR); + + foreach ($Files as $File) { + $DataProvider[] = + [ + file($File, FILE_SKIP_EMPTY_LINES | FILE_IGNORE_NEW_LINES), + json_decode(file_get_contents(str_replace('.raw', '.json', $File)), true) + ]; + } + + return $DataProvider; + } + + /** + * @dataProvider PlayersProvider + * @param array $RawInput + */ + public function testGetPlayers(array $RawInput, array $ExpectedOutput): void + { + $this->Socket->Queue(hex2bin("ffffffff4104fce20e")); // Challenge + + foreach ($RawInput as $Packet) { + $this->Socket->Queue(hex2bin($Packet)); + } + + $RealOutput = $this->SourceQuery->GetPlayers(); + + self::assertEquals($ExpectedOutput, $RealOutput); + } + + public function PlayersProvider(): array + { + $DataProvider = []; + + $Files = glob(__DIR__ . '/Players/*.raw', GLOB_ERR); + + foreach ($Files as $File) { + $DataProvider[] = + [ + file($File, FILE_SKIP_EMPTY_LINES | FILE_IGNORE_NEW_LINES), + json_decode(file_get_contents(str_replace('.raw', '.json', $File)), true) + ]; + } + + return $DataProvider; + } + + public function testPing(): void + { + $this->Socket->Queue("\xFF\xFF\xFF\xFF\x6A\x00"); + self::assertTrue($this->SourceQuery->Ping()); + + $this->Socket->Queue("\xFF\xFF\xFF\xFF\xEE"); + self::assertFalse($this->SourceQuery->Ping()); + } +} diff --git a/composer.json b/composer.json index 5a2d346..ed90e3a 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,8 @@ { "phpunit/phpunit": "^9.5", "vimeo/psalm": "^4.7", - "phpstan/phpstan": "^0.12.83" + "phpstan/phpstan": "^0.12.83", + "friendsofphp/php-cs-fixer": "^3.0" }, "autoload": {
Rules Rules