PocketMine-MP 5.18.1 git-9381fc4172e5dce4cada1cb356050c8a2ab57b94
UPnP.php
1<?php
2
3/*
4 *
5 * ____ _ _ __ __ _ __ __ ____
6 * | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
7 * | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
8 * | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
9 * |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
10 *
11 * This program is free software: you can redistribute it and/or modify
12 * it under the terms of the GNU Lesser General Public License as published by
13 * the Free Software Foundation, either version 3 of the License, or
14 * (at your option) any later version.
15 *
16 * @author PocketMine Team
17 * @link http://www.pocketmine.net/
18 *
19 *
20 */
21
22// This code is based on a Go implementation and its license is below:
23// Copyright (c) 2010 Jack Palevich. All rights reserved.
24//
25// Redistribution and use in source and binary forms, with or without
26// modification, are permitted provided that the following conditions are
27// met:
28//
29// * Redistributions of source code must retain the above copyright
30// notice, this list of conditions and the following disclaimer.
31// * Redistributions in binary form must reproduce the above
32// copyright notice, this list of conditions and the following disclaimer
33// in the documentation and/or other materials provided with the
34// distribution.
35// * Neither the name of Google Inc. nor the names of its
36// contributors may be used to endorse or promote products derived from
37// this software without specific prior written permission.
38//
39// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
40// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
41// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
42// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
43// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
44// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
45// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
46// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
47// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
48// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
49// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
50
51declare(strict_types=1);
52
57
60use function count;
61use function libxml_use_internal_errors;
62use function parse_url;
63use function preg_last_error;
64use function preg_match;
65use function socket_close;
66use function socket_create;
67use function socket_last_error;
68use function socket_recvfrom;
69use function socket_sendto;
70use function socket_set_option;
71use function socket_strerror;
72use function sprintf;
73use function strlen;
74use function trim;
75use const AF_INET;
76use const PREG_BACKTRACK_LIMIT_ERROR;
77use const PREG_BAD_UTF8_ERROR;
78use const PREG_BAD_UTF8_OFFSET_ERROR;
79use const PREG_INTERNAL_ERROR;
80use const PREG_JIT_STACKLIMIT_ERROR;
81use const PREG_RECURSION_LIMIT_ERROR;
82use const SO_RCVTIMEO;
83use const SOCK_DGRAM;
84use const SOCKET_ETIMEDOUT;
85use const SOL_SOCKET;
86use const SOL_UDP;
87
88class UPnP{
89 private const MAX_DISCOVERY_ATTEMPTS = 3;
90
91 private static function makePcreError() : \RuntimeException{
92 $errorCode = preg_last_error();
93 $message = [
94 PREG_INTERNAL_ERROR => "Internal error",
95 PREG_BACKTRACK_LIMIT_ERROR => "Backtrack limit reached",
96 PREG_RECURSION_LIMIT_ERROR => "Recursion limit reached",
97 PREG_BAD_UTF8_ERROR => "Malformed UTF-8",
98 PREG_BAD_UTF8_OFFSET_ERROR => "Bad UTF-8 offset",
99 PREG_JIT_STACKLIMIT_ERROR => "PCRE JIT stack limit reached"
100 ][$errorCode] ?? "Unknown (code $errorCode)";
101 throw new \RuntimeException("PCRE error: $message");
102 }
103
107 public static function getServiceUrl() : string{
108 $socket = Utils::assumeNotFalse(@socket_create(AF_INET, SOCK_DGRAM, SOL_UDP), fn() => "Socket error: " . trim(socket_strerror(socket_last_error())));
109 Utils::assumeNotFalse(@socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ["sec" => 3, "usec" => 0]), "Socket error: " . trim(socket_strerror(socket_last_error($socket))));
110 $contents =
111 "M-SEARCH * HTTP/1.1\r\n" .
112 "MX: 2\r\n" .
113 "HOST: 239.255.255.250:1900\r\n" .
114 "MAN: \"ssdp:discover\"\r\n" .
115 "ST: upnp:rootdevice\r\n\r\n";
116 $location = null;
117 for($i = 0; $i < self::MAX_DISCOVERY_ATTEMPTS; ++$i){
118 $sendbyte = @socket_sendto($socket, $contents, strlen($contents), 0, "239.255.255.250", 1900);
119 if($sendbyte === false){
120 throw new UPnPException("Socket error: " . trim(socket_strerror(socket_last_error($socket))));
121 }
122 if($sendbyte !== strlen($contents)){
123 throw new UPnPException("Socket error: Unable to send the entire contents.");
124 }
125 while(true){
126 if(@socket_recvfrom($socket, $buffer, 1024, 0, $responseHost, $responsePort) === false){
127 if(socket_last_error($socket) === SOCKET_ETIMEDOUT){
128 continue 2;
129 }
130 throw new UPnPException("Socket error: " . trim(socket_strerror(socket_last_error($socket))));
131 }
132 $pregResult = preg_match('/location\s*:\s*(.+)\n/i', $buffer, $matches);
133 if($pregResult === false){
134 //TODO: replace with preg_last_error_msg() in PHP 8.
135 throw self::makePcreError();
136 }
137 if($pregResult !== 0){ //this might be garbage from somewhere other than the router
138 $location = trim($matches[1]);
139 break 2;
140 }
141 }
142 }
143 socket_close($socket);
144 if($location === null){
145 throw new UPnPException("Unable to find the router. Ensure that network discovery is enabled in Control Panel.");
146 }
147 $url = parse_url($location);
148 if($url === false){
149 throw new UPnPException("Failed to parse the router's url: {$location}");
150 }
151 if(!isset($url['host'])){
152 throw new UPnPException("Failed to recognize the host name from the router's url: {$location}");
153 }
154 $urlHost = $url['host'];
155 if(!isset($url['port'])){
156 throw new UPnPException("Failed to recognize the port number from the router's url: {$location}");
157 }
158 $urlPort = $url['port'];
159 $err = "";
160 $response = Internet::getURL($location, 3, [], $err);
161 if($response === null){
162 throw new UPnPException("Unable to access XML: {$err}");
163 }
164 if($response->getCode() !== 200){
165 throw new UPnPException("Unable to access XML: {$response->getBody()}");
166 }
167
168 $defaultInternalError = libxml_use_internal_errors(true);
169 try{
170 $root = new \SimpleXMLElement($response->getBody());
171 }catch(\Exception $e){
172 throw new UPnPException("Broken XML.");
173 }
174 libxml_use_internal_errors($defaultInternalError);
175 $root->registerXPathNamespace("upnp", "urn:schemas-upnp-org:device-1-0");
176 $xpathResult = Utils::assumeNotFalse($root->xpath(
177 '//upnp:device[upnp:deviceType="urn:schemas-upnp-org:device:InternetGatewayDevice:1"]' .
178 '/upnp:deviceList/upnp:device[upnp:deviceType="urn:schemas-upnp-org:device:WANDevice:1"]' .
179 '/upnp:deviceList/upnp:device[upnp:deviceType="urn:schemas-upnp-org:device:WANConnectionDevice:1"]' .
180 '/upnp:serviceList/upnp:service[upnp:serviceType="urn:schemas-upnp-org:service:WANIPConnection:1"]' .
181 '/upnp:controlURL'
182 ), "xpath query is borked");
183
184 if($xpathResult === null || count($xpathResult) === 0){
185 throw new UPnPException("Your router does not support portforwarding");
186 }
187 $controlURL = (string) $xpathResult[0];
188 $serviceURL = sprintf("%s:%d/%s", $urlHost, $urlPort, $controlURL);
189 return $serviceURL;
190 }
191
195 public static function portForward(string $serviceURL, string $internalIP, int $internalPort, int $externalPort) : void{
196 $body =
197 '<u:AddPortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">' .
198 '<NewRemoteHost></NewRemoteHost>' .
199 '<NewExternalPort>' . $externalPort . '</NewExternalPort>' .
200 '<NewProtocol>UDP</NewProtocol>' .
201 '<NewInternalPort>' . $internalPort . '</NewInternalPort>' .
202 '<NewInternalClient>' . $internalIP . '</NewInternalClient>' .
203 '<NewEnabled>1</NewEnabled>' .
204 '<NewPortMappingDescription>PocketMine-MP</NewPortMappingDescription>' .
205 '<NewLeaseDuration>0</NewLeaseDuration>' .
206 '</u:AddPortMapping>';
207
208 $contents =
209 '<?xml version="1.0"?>' .
210 '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' .
211 '<s:Body>' . $body . '</s:Body></s:Envelope>';
212
213 $headers = [
214 'Content-Type: text/xml',
215 'SOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping"'
216 ];
217
218 if(Internet::postURL($serviceURL, $contents, 3, $headers, $err) === null){
219 throw new UPnPException("Failed to portforward using UPnP: " . $err);
220 }
221 }
222
223 public static function removePortForward(string $serviceURL, int $externalPort) : void{
224 $body =
225 '<u:DeletePortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">' .
226 '<NewRemoteHost></NewRemoteHost>' .
227 '<NewExternalPort>' . $externalPort . '</NewExternalPort>' .
228 '<NewProtocol>UDP</NewProtocol>' .
229 '</u:DeletePortMapping>';
230
231 $contents =
232 '<?xml version="1.0"?>' .
233 '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' .
234 '<s:Body>' . $body . '</s:Body></s:Envelope>';
235
236 $headers = [
237 'Content-Type: text/xml',
238 'SOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#DeletePortMapping"'
239 ];
240
241 Internet::postURL($serviceURL, $contents, 3, $headers);
242 }
243}
static portForward(string $serviceURL, string $internalIP, int $internalPort, int $externalPort)
Definition: UPnP.php:195
static getURL(string $page, int $timeout=10, array $extraHeaders=[], &$err=null)
Definition: Internet.php:147
static postURL(string $page, array|string $args, int $timeout=10, array $extraHeaders=[], &$err=null)
Definition: Internet.php:170
static assumeNotFalse(mixed $value, \Closure|string $context="This should never be false")
Definition: Utils.php:623