51declare(strict_types=1);
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;
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;
84use
const SOCKET_ETIMEDOUT;
89 private const MAX_DISCOVERY_ATTEMPTS = 3;
91 private static function makePcreError() : \RuntimeException{
92 $errorCode = preg_last_error();
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");
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))));
111 "M-SEARCH * HTTP/1.1\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";
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))));
122 if($sendbyte !== strlen($contents)){
123 throw new UPnPException(
"Socket error: Unable to send the entire contents.");
126 if(@socket_recvfrom($socket, $buffer, 1024, 0, $responseHost, $responsePort) ===
false){
127 if(socket_last_error($socket) === SOCKET_ETIMEDOUT){
130 throw new UPnPException(
"Socket error: " . trim(socket_strerror(socket_last_error($socket))));
132 $pregResult = preg_match(
'/location\s*:\s*(.+)\n/i', $buffer, $matches);
133 if($pregResult ===
false){
135 throw self::makePcreError();
137 if($pregResult !== 0){
138 $location = trim($matches[1]);
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.");
147 $url = parse_url($location);
149 throw new UPnPException(
"Failed to parse the router's url: {$location}");
151 if(!isset($url[
'host'])){
152 throw new UPnPException(
"Failed to recognize the host name from the router's url: {$location}");
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}");
158 $urlPort = $url[
'port'];
161 if($response ===
null){
162 throw new UPnPException(
"Unable to access XML: {$err}");
164 if($response->getCode() !== 200){
165 throw new UPnPException(
"Unable to access XML: {$response->getBody()}");
168 $defaultInternalError = libxml_use_internal_errors(
true);
170 $root = new \SimpleXMLElement($response->getBody());
171 }
catch(\Exception $e){
172 throw new UPnPException(
"Broken XML.");
174 libxml_use_internal_errors($defaultInternalError);
175 $root->registerXPathNamespace(
"upnp",
"urn:schemas-upnp-org:device-1-0");
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"]' .
182 ),
"xpath query is borked");
184 if($xpathResult ===
null || count($xpathResult) === 0){
185 throw new UPnPException(
"Your router does not support portforwarding");
187 $controlURL = (string) $xpathResult[0];
188 $serviceURL = sprintf(
"%s:%d/%s", $urlHost, $urlPort, $controlURL);
195 public static function portForward(
string $serviceURL,
string $internalIP,
int $internalPort,
int $externalPort) : void{
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>';
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>';
214 'Content-Type: text/xml',
215 'SOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping"'
219 throw new UPnPException(
"Failed to portforward using UPnP: " . $err);
223 public static function removePortForward(
string $serviceURL,
int $externalPort) : void{
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>';
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>';
237 'Content-Type: text/xml',
238 'SOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#DeletePortMapping"'
241 Internet::postURL($serviceURL, $contents, 3, $headers);
static portForward(string $serviceURL, string $internalIP, int $internalPort, int $externalPort)
static getURL(string $page, int $timeout=10, array $extraHeaders=[], &$err=null)
static postURL(string $page, array|string $args, int $timeout=10, array $extraHeaders=[], &$err=null)
static assumeNotFalse(mixed $value, \Closure|string $context="This should never be false")