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
-