friendship ended with social-app. php is my new best friend
1<?php 2 3namespace React\Dns\Query; 4 5use React\Dns\Model\Message; 6use React\Dns\Protocol\BinaryDumper; 7use React\Dns\Protocol\Parser; 8use React\EventLoop\Loop; 9use React\EventLoop\LoopInterface; 10use React\Promise\Deferred; 11 12/** 13 * Send DNS queries over a TCP/IP stream transport. 14 * 15 * This is one of the main classes that send a DNS query to your DNS server. 16 * 17 * For more advanced usages one can utilize this class directly. 18 * The following example looks up the `IPv6` address for `reactphp.org`. 19 * 20 * ```php 21 * $executor = new TcpTransportExecutor('8.8.8.8:53'); 22 * 23 * $executor->query( 24 * new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) 25 * )->then(function (Message $message) { 26 * foreach ($message->answers as $answer) { 27 * echo 'IPv6: ' . $answer->data . PHP_EOL; 28 * } 29 * }, 'printf'); 30 * ``` 31 * 32 * See also [example #92](examples). 33 * 34 * Note that this executor does not implement a timeout, so you will very likely 35 * want to use this in combination with a `TimeoutExecutor` like this: 36 * 37 * ```php 38 * $executor = new TimeoutExecutor( 39 * new TcpTransportExecutor($nameserver), 40 * 3.0 41 * ); 42 * ``` 43 * 44 * Unlike the `UdpTransportExecutor`, this class uses a reliable TCP/IP 45 * transport, so you do not necessarily have to implement any retry logic. 46 * 47 * Note that this executor is entirely async and as such allows you to execute 48 * queries concurrently. The first query will establish a TCP/IP socket 49 * connection to the DNS server which will be kept open for a short period. 50 * Additional queries will automatically reuse this existing socket connection 51 * to the DNS server, will pipeline multiple requests over this single 52 * connection and will keep an idle connection open for a short period. The 53 * initial TCP/IP connection overhead may incur a slight delay if you only send 54 * occasional queries – when sending a larger number of concurrent queries over 55 * an existing connection, it becomes increasingly more efficient and avoids 56 * creating many concurrent sockets like the UDP-based executor. You may still 57 * want to limit the number of (concurrent) queries in your application or you 58 * may be facing rate limitations and bans on the resolver end. For many common 59 * applications, you may want to avoid sending the same query multiple times 60 * when the first one is still pending, so you will likely want to use this in 61 * combination with a `CoopExecutor` like this: 62 * 63 * ```php 64 * $executor = new CoopExecutor( 65 * new TimeoutExecutor( 66 * new TcpTransportExecutor($nameserver), 67 * 3.0 68 * ) 69 * ); 70 * ``` 71 * 72 * > Internally, this class uses PHP's TCP/IP sockets and does not take advantage 73 * of [react/socket](https://github.com/reactphp/socket) purely for 74 * organizational reasons to avoid a cyclic dependency between the two 75 * packages. Higher-level components should take advantage of the Socket 76 * component instead of reimplementing this socket logic from scratch. 77 */ 78class TcpTransportExecutor implements ExecutorInterface 79{ 80 private $nameserver; 81 private $loop; 82 private $parser; 83 private $dumper; 84 85 /** 86 * @var ?resource 87 */ 88 private $socket; 89 90 /** 91 * @var Deferred[] 92 */ 93 private $pending = array(); 94 95 /** 96 * @var string[] 97 */ 98 private $names = array(); 99 100 /** 101 * Maximum idle time when socket is current unused (i.e. no pending queries outstanding) 102 * 103 * If a new query is to be sent during the idle period, we can reuse the 104 * existing socket without having to wait for a new socket connection. 105 * This uses a rather small, hard-coded value to not keep any unneeded 106 * sockets open and to not keep the loop busy longer than needed. 107 * 108 * A future implementation may take advantage of `edns-tcp-keepalive` to keep 109 * the socket open for longer periods. This will likely require explicit 110 * configuration because this may consume additional resources and also keep 111 * the loop busy for longer than expected in some applications. 112 * 113 * @var float 114 * @link https://tools.ietf.org/html/rfc7766#section-6.2.1 115 * @link https://tools.ietf.org/html/rfc7828 116 */ 117 private $idlePeriod = 0.001; 118 119 /** 120 * @var ?\React\EventLoop\TimerInterface 121 */ 122 private $idleTimer; 123 124 private $writeBuffer = ''; 125 private $writePending = false; 126 127 private $readBuffer = ''; 128 private $readPending = false; 129 130 /** @var string */ 131 private $readChunk = 0xffff; 132 133 /** 134 * @param string $nameserver 135 * @param ?LoopInterface $loop 136 */ 137 public function __construct($nameserver, $loop = null) 138 { 139 if (\strpos($nameserver, '[') === false && \substr_count($nameserver, ':') >= 2 && \strpos($nameserver, '://') === false) { 140 // several colons, but not enclosed in square brackets => enclose IPv6 address in square brackets 141 $nameserver = '[' . $nameserver . ']'; 142 } 143 144 $parts = \parse_url((\strpos($nameserver, '://') === false ? 'tcp://' : '') . $nameserver); 145 if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'tcp' || @\inet_pton(\trim($parts['host'], '[]')) === false) { 146 throw new \InvalidArgumentException('Invalid nameserver address given'); 147 } 148 149 if ($loop !== null && !$loop instanceof LoopInterface) { // manual type check to support legacy PHP < 7.1 150 throw new \InvalidArgumentException('Argument #2 ($loop) expected null|React\EventLoop\LoopInterface'); 151 } 152 153 $this->nameserver = 'tcp://' . $parts['host'] . ':' . (isset($parts['port']) ? $parts['port'] : 53); 154 $this->loop = $loop ?: Loop::get(); 155 $this->parser = new Parser(); 156 $this->dumper = new BinaryDumper(); 157 } 158 159 public function query(Query $query) 160 { 161 $request = Message::createRequestForQuery($query); 162 163 // keep shuffing message ID to avoid using the same message ID for two pending queries at the same time 164 while (isset($this->pending[$request->id])) { 165 $request->id = \mt_rand(0, 0xffff); // @codeCoverageIgnore 166 } 167 168 $queryData = $this->dumper->toBinary($request); 169 $length = \strlen($queryData); 170 if ($length > 0xffff) { 171 return \React\Promise\reject(new \RuntimeException( 172 'DNS query for ' . $query->describe() . ' failed: Query too large for TCP transport' 173 )); 174 } 175 176 $queryData = \pack('n', $length) . $queryData; 177 178 if ($this->socket === null) { 179 // create async TCP/IP connection (may take a while) 180 $socket = @\stream_socket_client($this->nameserver, $errno, $errstr, 0, \STREAM_CLIENT_CONNECT | \STREAM_CLIENT_ASYNC_CONNECT); 181 if ($socket === false) { 182 return \React\Promise\reject(new \RuntimeException( 183 'DNS query for ' . $query->describe() . ' failed: Unable to connect to DNS server ' . $this->nameserver . ' (' . $errstr . ')', 184 $errno 185 )); 186 } 187 188 // set socket to non-blocking and wait for it to become writable (connection success/rejected) 189 \stream_set_blocking($socket, false); 190 if (\function_exists('stream_set_chunk_size')) { 191 \stream_set_chunk_size($socket, $this->readChunk); // @codeCoverageIgnore 192 } 193 $this->socket = $socket; 194 } 195 196 if ($this->idleTimer !== null) { 197 $this->loop->cancelTimer($this->idleTimer); 198 $this->idleTimer = null; 199 } 200 201 // wait for socket to become writable to actually write out data 202 $this->writeBuffer .= $queryData; 203 if (!$this->writePending) { 204 $this->writePending = true; 205 $this->loop->addWriteStream($this->socket, array($this, 'handleWritable')); 206 } 207 208 $names =& $this->names; 209 $that = $this; 210 $deferred = new Deferred(function () use ($that, &$names, $request) { 211 // remove from list of pending names, but remember pending query 212 $name = $names[$request->id]; 213 unset($names[$request->id]); 214 $that->checkIdle(); 215 216 throw new CancellationException('DNS query for ' . $name . ' has been cancelled'); 217 }); 218 219 $this->pending[$request->id] = $deferred; 220 $this->names[$request->id] = $query->describe(); 221 222 return $deferred->promise(); 223 } 224 225 /** 226 * @internal 227 */ 228 public function handleWritable() 229 { 230 if ($this->readPending === false) { 231 $name = @\stream_socket_get_name($this->socket, true); 232 if ($name === false) { 233 // Connection failed? Check socket error if available for underlying errno/errstr. 234 // @codeCoverageIgnoreStart 235 if (\function_exists('socket_import_stream')) { 236 $socket = \socket_import_stream($this->socket); 237 $errno = \socket_get_option($socket, \SOL_SOCKET, \SO_ERROR); 238 $errstr = \socket_strerror($errno); 239 } else { 240 $errno = \defined('SOCKET_ECONNREFUSED') ? \SOCKET_ECONNREFUSED : 111; 241 $errstr = 'Connection refused'; 242 } 243 // @codeCoverageIgnoreEnd 244 245 $this->closeError('Unable to connect to DNS server ' . $this->nameserver . ' (' . $errstr . ')', $errno); 246 return; 247 } 248 249 $this->readPending = true; 250 $this->loop->addReadStream($this->socket, array($this, 'handleRead')); 251 } 252 253 $errno = 0; 254 $errstr = ''; 255 \set_error_handler(function ($_, $error) use (&$errno, &$errstr) { 256 // Match errstr from PHP's warning message. 257 // fwrite(): Send of 327712 bytes failed with errno=32 Broken pipe 258 \preg_match('/errno=(\d+) (.+)/', $error, $m); 259 $errno = isset($m[1]) ? (int) $m[1] : 0; 260 $errstr = isset($m[2]) ? $m[2] : $error; 261 }); 262 263 $written = \fwrite($this->socket, $this->writeBuffer); 264 265 \restore_error_handler(); 266 267 if ($written === false || $written === 0) { 268 $this->closeError( 269 'Unable to send query to DNS server ' . $this->nameserver . ' (' . $errstr . ')', 270 $errno 271 ); 272 return; 273 } 274 275 if (isset($this->writeBuffer[$written])) { 276 $this->writeBuffer = \substr($this->writeBuffer, $written); 277 } else { 278 $this->loop->removeWriteStream($this->socket); 279 $this->writePending = false; 280 $this->writeBuffer = ''; 281 } 282 } 283 284 /** 285 * @internal 286 */ 287 public function handleRead() 288 { 289 // read one chunk of data from the DNS server 290 // any error is fatal, this is a stream of TCP/IP data 291 $chunk = @\fread($this->socket, $this->readChunk); 292 if ($chunk === false || $chunk === '') { 293 $this->closeError('Connection to DNS server ' . $this->nameserver . ' lost'); 294 return; 295 } 296 297 // reassemble complete message by concatenating all chunks. 298 $this->readBuffer .= $chunk; 299 300 // response message header contains at least 12 bytes 301 while (isset($this->readBuffer[11])) { 302 // read response message length from first 2 bytes and ensure we have length + data in buffer 303 list(, $length) = \unpack('n', $this->readBuffer); 304 if (!isset($this->readBuffer[$length + 1])) { 305 return; 306 } 307 308 $data = \substr($this->readBuffer, 2, $length); 309 $this->readBuffer = (string)substr($this->readBuffer, $length + 2); 310 311 try { 312 $response = $this->parser->parseMessage($data); 313 } catch (\Exception $e) { 314 // reject all pending queries if we received an invalid message from remote server 315 $this->closeError('Invalid message received from DNS server ' . $this->nameserver); 316 return; 317 } 318 319 // reject all pending queries if we received an unexpected response ID or truncated response 320 if (!isset($this->pending[$response->id]) || $response->tc) { 321 $this->closeError('Invalid response message received from DNS server ' . $this->nameserver); 322 return; 323 } 324 325 $deferred = $this->pending[$response->id]; 326 unset($this->pending[$response->id], $this->names[$response->id]); 327 328 $deferred->resolve($response); 329 330 $this->checkIdle(); 331 } 332 } 333 334 /** 335 * @internal 336 * @param string $reason 337 * @param int $code 338 */ 339 public function closeError($reason, $code = 0) 340 { 341 $this->readBuffer = ''; 342 if ($this->readPending) { 343 $this->loop->removeReadStream($this->socket); 344 $this->readPending = false; 345 } 346 347 $this->writeBuffer = ''; 348 if ($this->writePending) { 349 $this->loop->removeWriteStream($this->socket); 350 $this->writePending = false; 351 } 352 353 if ($this->idleTimer !== null) { 354 $this->loop->cancelTimer($this->idleTimer); 355 $this->idleTimer = null; 356 } 357 358 @\fclose($this->socket); 359 $this->socket = null; 360 361 foreach ($this->names as $id => $name) { 362 $this->pending[$id]->reject(new \RuntimeException( 363 'DNS query for ' . $name . ' failed: ' . $reason, 364 $code 365 )); 366 } 367 $this->pending = $this->names = array(); 368 } 369 370 /** 371 * @internal 372 */ 373 public function checkIdle() 374 { 375 if ($this->idleTimer === null && !$this->names) { 376 $that = $this; 377 $this->idleTimer = $this->loop->addTimer($this->idlePeriod, function () use ($that) { 378 $that->closeError('Idle timeout'); 379 }); 380 } 381 } 382}