Implement UPnP support without dotNET (#3378)

UPnP forwarding is now available on all supported platforms.
com_dotnet is no longer required for UPnP forwarding to work.

Closes #3216 .
This commit is contained in:
Yosshi999 2021-03-15 07:50:33 +09:00 committed by GitHub
parent edcf296086
commit cbc8576d4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 211 additions and 42 deletions

View File

@ -18,6 +18,7 @@
"ext-phar": "*",
"ext-pthreads": "~3.2.0",
"ext-reflection": "*",
"ext-simplexml": "*",
"ext-sockets": "*",
"ext-spl": "*",
"ext-yaml": ">=2.0.0",

3
composer.lock generated
View File

@ -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",

View File

@ -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

View File

@ -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 =
'<u:AddPortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">' .
'<NewRemoteHost></NewRemoteHost>' .
'<NewExternalPort>' . $port . '</NewExternalPort>' .
'<NewProtocol>UDP</NewProtocol>' .
'<NewInternalPort>' . $port . '</NewInternalPort>' .
'<NewInternalClient>' . Internet::getInternalIP() . '</NewInternalClient>' .
'<NewEnabled>1</NewEnabled>' .
'<NewPortMappingDescription>PocketMine-MP</NewPortMappingDescription>' .
'<NewLeaseDuration>0</NewLeaseDuration>' .
'</u:AddPortMapping>';
$contents =
'<?xml version="1.0"?>' .
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' .
'<s:Body>' . $body . '</s:Body></s:Envelope>';
$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 =
'<u:DeletePortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">' .
'<NewRemoteHost></NewRemoteHost>' .
'<NewExternalPort>' . $port . '</NewExternalPort>' .
'<NewProtocol>UDP</NewProtocol>' .
'</u:DeletePortMapping>';
$contents =
'<?xml version="1.0"?>' .
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' .
'<s:Body>' . $body . '</s:Body></s:Envelope>';
$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;
}

View File

@ -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.

View File

@ -1,7 +0,0 @@
parameters:
ignoreErrors:
-
message: "#^Access to an undefined property COM\\:\\:\\$StaticPortMappingCollection\\.$#"
count: 2
path: ../../../src/pocketmine/network/upnp/UPnP.php