diff --git a/composer.json b/composer.json index 89f7f99ea..dc38aa48e 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "ext-phar": "*", "ext-pthreads": "~3.2.0", "ext-reflection": "*", + "ext-simplexml": "*", "ext-sockets": "*", "ext-spl": "*", "ext-yaml": ">=2.0.0", diff --git a/composer.lock b/composer.lock index c492757c2..34154cd9f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ef9b64ade88849af6ccd0db733e106b5", + "content-hash": "c731270da66df8e18ea9487d91bc2e3f", "packages": [ { "name": "adhocore/json-comment", @@ -2753,6 +2753,7 @@ "ext-phar": "*", "ext-pthreads": "~3.2.0", "ext-reflection": "*", + "ext-simplexml": "*", "ext-sockets": "*", "ext-spl": "*", "ext-yaml": ">=2.0.0", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 7586d7e11..84a7d0141 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,7 +1,6 @@ includes: - tests/phpstan/configs/actual-problems.neon - tests/phpstan/configs/check-explicit-mixed-baseline.neon - - tests/phpstan/configs/com-dotnet-magic.neon - tests/phpstan/configs/gc-hacks.neon - tests/phpstan/configs/l7-baseline.neon - tests/phpstan/configs/l8-baseline.neon diff --git a/src/pocketmine/network/upnp/UPnP.php b/src/pocketmine/network/upnp/UPnP.php index 6fe5a6951..54d5a824c 100644 --- a/src/pocketmine/network/upnp/UPnP.php +++ b/src/pocketmine/network/upnp/UPnP.php @@ -19,63 +19,238 @@ * */ +// This code is based on a Go implementation and its license is below: +// Copyright (c) 2010 Jack Palevich. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + declare(strict_types=1); /** - * UPnP port forwarding support. Only for Windows + * UPnP port forwarding support. */ namespace pocketmine\network\upnp; +use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\Internet; -use pocketmine\utils\Utils; -use function class_exists; -use function is_object; +use function count; +use function libxml_use_internal_errors; +use function parse_url; +use function preg_last_error; +use function preg_match; +use function socket_close; +use function socket_create; +use function socket_last_error; +use function socket_recvfrom; +use function socket_sendto; +use function socket_set_option; +use function socket_strerror; +use function sprintf; +use function strlen; +use function trim; +use const AF_INET; +use const SO_RCVTIMEO; +use const SOCK_DGRAM; +use const SOCKET_ETIMEDOUT; +use const SOL_SOCKET; +use const SOL_UDP; abstract class UPnP{ + private const MAX_DISCOVERY_ATTEMPTS = 3; + + /** @var string|null */ + private static $serviceURL = null; + + private static function makePcreError() : \RuntimeException{ + $errorCode = preg_last_error(); + $message = [ + PREG_INTERNAL_ERROR => "Internal error", + PREG_BACKTRACK_LIMIT_ERROR => "Backtrack limit reached", + PREG_RECURSION_LIMIT_ERROR => "Recursion limit reached", + PREG_BAD_UTF8_ERROR => "Malformed UTF-8", + PREG_BAD_UTF8_OFFSET_ERROR => "Bad UTF-8 offset", + PREG_JIT_STACKLIMIT_ERROR => "PCRE JIT stack limit reached" + ][$errorCode] ?? "Unknown (code $errorCode)"; + throw new \RuntimeException("PCRE error: $message"); + } + + public static function getServiceUrl() : string{ + $socket = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); + if($socket === false){ + throw new \RuntimeException("Socket error: " . trim(socket_strerror(socket_last_error()))); + } + if(!@socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ["sec" => 3, "usec" => 0])){ + throw new \RuntimeException("Socket error: " . trim(socket_strerror(socket_last_error($socket)))); + } + $contents = + "M-SEARCH * HTTP/1.1\r\n" . + "MX: 2\r\n" . + "HOST: 239.255.255.250:1900\r\n" . + "MAN: \"ssdp:discover\"\r\n" . + "ST: upnp:rootdevice\r\n\r\n"; + $location = null; + for($i = 0; $i < self::MAX_DISCOVERY_ATTEMPTS; ++$i){ + $sendbyte = @socket_sendto($socket, $contents, strlen($contents), 0, "239.255.255.250", 1900); + if($sendbyte === false){ + throw new \RuntimeException("Socket error: " . trim(socket_strerror(socket_last_error($socket)))); + } + if($sendbyte !== strlen($contents)){ + throw new \RuntimeException("Socket error: Unable to send the entire contents."); + } + while(true){ + if(@socket_recvfrom($socket, $buffer, 1024, 0, $responseHost, $responsePort) === false){ + if(socket_last_error($socket) === SOCKET_ETIMEDOUT){ + continue 2; + } + throw new \RuntimeException("Socket error: " . trim(socket_strerror(socket_last_error($socket)))); + } + $pregResult = preg_match('/location\s*:\s*(.+)\n/i', $buffer, $matches); + if($pregResult === false){ + //TODO: replace with preg_last_error_msg() in PHP 8. + throw self::makePcreError(); + } + if($pregResult !== 0){ //this might be garbage from somewhere other than the router + $location = trim($matches[1]); + break 2; + } + } + } + socket_close($socket); + if($location === null){ + throw new \RuntimeException("Unable to find the router. Ensure that network discovery is enabled in Control Panel."); + } + $url = parse_url($location); + if($url === false){ + throw new \RuntimeException("Failed to parse the router's url: {$location}"); + } + if(!isset($url['host'])){ + throw new \RuntimeException("Failed to recognize the host name from the router's url: {$location}"); + } + $urlHost = $url['host']; + if(!isset($url['port'])){ + throw new \RuntimeException("Failed to recognize the port number from the router's url: {$location}"); + } + $urlPort = $url['port']; + $response = Internet::getURL($location, 3, [], $err, $headers, $httpCode); + if($response === false){ + throw new \RuntimeException("Unable to access XML: {$err}"); + } + if($httpCode !== 200){ + throw new \RuntimeException("Unable to access XML: {$response}"); + } + + $defaultInternalError = libxml_use_internal_errors(true); + try{ + $root = new \SimpleXMLElement($response); + }catch(\Exception $e){ + throw new \RuntimeException("Broken XML."); + } + libxml_use_internal_errors($defaultInternalError); + $root->registerXPathNamespace("upnp", "urn:schemas-upnp-org:device-1-0"); + $xpathResult = $root->xpath( + '//upnp:device[upnp:deviceType="urn:schemas-upnp-org:device:InternetGatewayDevice:1"]' . + '/upnp:deviceList/upnp:device[upnp:deviceType="urn:schemas-upnp-org:device:WANDevice:1"]' . + '/upnp:deviceList/upnp:device[upnp:deviceType="urn:schemas-upnp-org:device:WANConnectionDevice:1"]' . + '/upnp:serviceList/upnp:service[upnp:serviceType="urn:schemas-upnp-org:service:WANIPConnection:1"]' . + '/upnp:controlURL' + ); + if($xpathResult === false){ + //this should be an array of 0 if there is no matching elements; false indicates a problem with the query itself + throw new AssumptionFailedError("xpath query should not error here"); + } + if(count($xpathResult) === 0){ + throw new \RuntimeException("Your router does not support portforwarding"); + } + $controlURL = (string) $xpathResult[0]; + $serviceURL = sprintf("%s:%d/%s", $urlHost, $urlPort, $controlURL); + return $serviceURL; + } public static function PortForward(int $port) : void{ if(!Internet::$online){ throw new \RuntimeException("Server is offline"); } - if(Utils::getOS() !== Utils::OS_WINDOWS){ - throw new \RuntimeException("UPnP is only supported on Windows"); + + if(self::$serviceURL === null){ + self::$serviceURL = self::getServiceUrl(); } - if(!class_exists("COM")){ - throw new \RuntimeException("UPnP requires the com_dotnet extension"); + $body = + '' . + '' . + '' . $port . '' . + 'UDP' . + '' . $port . '' . + '' . Internet::getInternalIP() . '' . + '1' . + 'PocketMine-MP' . + '0' . + ''; + + $contents = + '' . + '' . + '' . $body . ''; + + $headers = [ + 'Content-Type: text/xml', + 'SOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping"' + ]; + + if(Internet::postURL(self::$serviceURL, $contents, 3, $headers, $err) === false){ + throw new \RuntimeException("Failed to portforward using UPnP: " . $err); } - - $myLocalIP = Internet::getInternalIP(); - - /** @noinspection PhpUndefinedClassInspection */ - $com = new \COM("HNetCfg.NATUPnP"); - /** @noinspection PhpUndefinedFieldInspection */ - - if(!is_object($com->StaticPortMappingCollection)){ - throw new \RuntimeException("Failed to portforward using UPnP. Ensure that network discovery is enabled in Control Panel."); - } - - /** @noinspection PhpUndefinedFieldInspection */ - $com->StaticPortMappingCollection->Add($port, "UDP", $port, $myLocalIP, true, "PocketMine-MP"); } public static function RemovePortForward(int $port) : bool{ if(!Internet::$online){ return false; } - if(Utils::getOS() !== Utils::OS_WINDOWS or !class_exists("COM")){ + if(self::$serviceURL === null){ return false; } - try{ - /** @noinspection PhpUndefinedClassInspection */ - $com = new \COM("HNetCfg.NATUPnP"); - /** @noinspection PhpUndefinedFieldInspection */ - if(!is_object($com->StaticPortMappingCollection)){ - return false; - } - /** @noinspection PhpUndefinedFieldInspection */ - $com->StaticPortMappingCollection->Remove($port, "UDP"); - }catch(\Throwable $e){ + $body = + '' . + '' . + '' . $port . '' . + 'UDP' . + ''; + + $contents = + '' . + '' . + '' . $body . ''; + + $headers = [ + 'Content-Type: text/xml', + 'SOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#DeletePortMapping"' + ]; + + if(Internet::postURL(self::$serviceURL, $contents, 3, $headers) === false){ return false; } diff --git a/src/pocketmine/resources/pocketmine.yml b/src/pocketmine/resources/pocketmine.yml index a513e7993..666da755b 100644 --- a/src/pocketmine/resources/pocketmine.yml +++ b/src/pocketmine/resources/pocketmine.yml @@ -87,7 +87,7 @@ network: compression-level: 6 #Use AsyncTasks for compression. Adds half/one tick delay, less CPU load on main thread async-compression: false - #Experimental, only for Windows. Tries to use UPnP to automatically port forward + #Experimental. Use UPnP to automatically port forward upnp-forwarding: false #Maximum size in bytes of packets sent over the network (default 1492 bytes). Packets larger than this will be #fragmented or split into smaller parts. Clients can request MTU sizes up to but not more than this number. diff --git a/tests/phpstan/configs/com-dotnet-magic.neon b/tests/phpstan/configs/com-dotnet-magic.neon deleted file mode 100644 index bb0f2cbad..000000000 --- a/tests/phpstan/configs/com-dotnet-magic.neon +++ /dev/null @@ -1,7 +0,0 @@ -parameters: - ignoreErrors: - - - message: "#^Access to an undefined property COM\\:\\:\\$StaticPortMappingCollection\\.$#" - count: 2 - path: ../../../src/pocketmine/network/upnp/UPnP.php -