diff --git a/README.md b/README.md index 0295195..cd93cb0 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,17 @@ PHP 8 library for Half-Life API with native IPv6 / Yggdrasil support #### Master ``` -$master = new Yggverse\Hl\Xash3d\Master('hl.ygg', 27010); +$master = new \Yggverse\Hl\Xash3D\Master('hl.ygg', 27010); var_dump( $master->getServersIPv6() ); -``` \ No newline at end of file + +var_dump( + $master->getErrors() +); +``` + +## Projects + +* [HLState](https://github.com/YGGverse/HLState) - Web Monitor for Half-Life Servers \ No newline at end of file diff --git a/composer.json b/composer.json index 55c3533..cf92306 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,8 @@ { "name": "yggverse/hl", "description": "PHP 8 library for Half-Life API with native IPv6 / Yggdrasil support ", + "keywords": [ "half-life", "hl", "xash3d", "ipv6", "yggdrasil", "master" ], + "homepage": "https://github.com/YGGverse/hl-php", "type": "library", "license": "MIT", "autoload": { diff --git a/src/Xash3D/Master.php b/src/Xash3D/Master.php index c748d53..5ea743e 100644 --- a/src/Xash3D/Master.php +++ b/src/Xash3D/Master.php @@ -2,66 +2,127 @@ declare(strict_types=1); -namespace Yggverse\Hl\Xash3d; +namespace Yggverse\Hl\Xash3D; + +enum Family: int { + case IPv4 = 4; + case IPv6 = 16; +} + +enum Region: string { + case USEastCoast = "0x00"; + case USWestCoast = "0x01"; + case SouthAmerica = "0x02"; + case Europe = "0x03"; + case Asia = "0x04"; + case Australia = "0x05"; + case MiddleEast = "0x06"; + case Africa = "0x07"; + case World = "0xff"; +} + +enum Game: string { + case Valve = "valve"; +} class Master { - private $_socket; + private string $_host; + private int $_port; + private int $_timeout; + + private array $_errors = []; public function __construct( string $host, - int $port, + int $port = 27010, int $timeout = 5 ) { - $this->_socket = fsockopen( - "udp://{$host}", - $port - ); - - stream_set_timeout( - $this->_socket, - $timeout - ); + $this->_host = $host; + $this->_port = $port; + $this->_timeout = $timeout; } - public function __destruct() + private function _fclose( + mixed $socket + ) { - if ($this->_socket) + if (true === is_resource($socket)) { fclose( - $this->_socket + $socket ); } } - public static function getServersIPv6( + // Legacy protocol implementation does not support mixed address families + // in the binary master socket response, use separated method for IPv4 servers. + public function getServers( int $limit = 100, - string $region = "\xFF", - string $host = "0.0.0.0:0", + string $host = "0.0.0.0", int $port = 0, - string $gamedir = "valve" + Game $game = Game::Valve, + Region $region = Region::World ): ?array { + $family = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? Family::IPv4 : Family::IPv6; + + // Init connection + $socket = fsockopen( + "udp://{$this->_host}", + $this->_port, + $code, + $message, + $this->_timeout + ); + + $master = "{$this->_host}:{$this->_port}"; + // Is connected - if (!$this->_socket) + if (true === is_resource($socket)) { + stream_set_timeout( + $socket, + $this->_timeout + ); + } + + else + { + $this->_errors[] = sprintf( + _("Connection error for $master: %s"), + $message + ); + + $this->_fclose( + $socket + ); + return null; } // Filter query - if (!fwrite($this->_socket, "1{$region}{$host}:{$port}\0\gamedir\t{$gamedir}\0")) + if (false === fwrite($socket, "1{$region->value}{$host}:{$port}\0\\gamedir\\{$game->value}\0")) { - fclose( - $this->_socket + $this->_errors[] = _("Could not send socket query for $master"); + + $this->_fclose( + $socket ); return null; } // Skip header - if (!fread($this->_socket, 6)) + if (false === fread($socket, 6)) { + $this->_errors[] = _("Could not init packet header for $master"); + + $this->_fclose( + $socket + ); + return null; } @@ -70,56 +131,64 @@ class Master for ($i = 0; $i < $limit; $i++) { - // Get host - if (false === $host = fread($this->_socket, 16)) + // Get host bytes + if (false === $host = fread($socket, $family->value)) { - return null; + $this->_errors[] = _("Invalid `host` fragment in packet at $i for $master"); + break; } - // Is end of packet - if (true === str_starts_with($host, 0)) + // End of packet + if (true === str_ends_with(bin2hex($host), bin2hex("\0\0\0\0\0\0"))) { break; } - // Skip invalid host + // Get host string if (false === $host = inet_ntop($host)) { - continue; + $this->_errors[] = _("Invalid `host` value in packet at $i for $master"); + break; } - // Decode first byte for port - if (false === $byte1 = fread($this->_socket, 1)) + // Get port bytes + if (false === $p = fread($socket, 2)) { - return null; + $this->_errors[] = _("Invalid `port` fragment in packet at $i for $master"); + break; } - // Decode second byte for port - if (false === $byte2 = fread($this->_socket, 1)) + // Get port value + if (false === $p = unpack('nport', $p)) { - return null; + $this->_errors[] = _("Invalid `port` value in packet at $i for $master"); + break; } - // Calculate port value - $port = ord($byte1) * 256 + ord($byte2); - - // Validate IPv6 result - if ( - false !== strpos($host, '.') || // filter_var not always works with mixed IPv6 - false === filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) || - false === filter_var($port, FILTER_VALIDATE_INT) - ) + // Validate result + if (false === filter_var($host, FILTER_VALIDATE_IP, $family == Family::IPv6 ? FILTER_FLAG_IPV6 + : FILTER_FLAG_IPV4) || empty($p['port'])) { - continue; + $this->_errors[] = _("Invalid socket address in packet at $i for $master"); + break; } - $servers["[{$host}]:{$port}"] = // keep unique + $servers["{$host}{$p['port']}"] = // keep unique [ 'host' => $host, - 'port' => $port + 'port' => $p['port'] ]; } + $this->_fclose( + $socket + ); + return $servers; } + + public function getErrors(): array + { + return $this->_errors; + } } \ No newline at end of file