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}