Skip to content

Commit f7f358f

Browse files
committed
Add TcpTransportExecutor to send DNS queries over TCP/IP connection
This changeset implements a fully functional TCP/IP transport for DNS queries. In its current form, it requires manual setup (see examples) and only the UDP transport is used by default. A follow-up PR will implement proper transport selection based on query type and whether a UDP query resulted in a truncated response.
1 parent a04f6f2 commit f7f358f

File tree

5 files changed

+729
-5
lines changed

5 files changed

+729
-5
lines changed

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ easily be used to create a DNS server.
1818
* [resolveAll()](#resolveall)
1919
* [Advanced usage](#advanced-usage)
2020
* [UdpTransportExecutor](#udptransportexecutor)
21+
* [TcpTransportExecutor](#tcptransportexecutor)
2122
* [HostsFileExecutor](#hostsfileexecutor)
2223
* [Install](#install)
2324
* [Tests](#tests)
@@ -276,6 +277,71 @@ $executor = new CoopExecutor(
276277
packages. Higher-level components should take advantage of the Datagram
277278
component instead of reimplementing this socket logic from scratch.
278279

280+
### TcpTransportExecutor
281+
282+
The `TcpTransportExecutor` class can be used to
283+
send DNS queries over a TCP/IP stream transport.
284+
285+
This is one of the main classes that send a DNS query to your DNS server.
286+
287+
For more advanced usages one can utilize this class directly.
288+
The following example looks up the `IPv6` address for `reactphp.org`.
289+
290+
```php
291+
$loop = Factory::create();
292+
$executor = new TcpTransportExecutor('8.8.8.8:53', $loop);
293+
294+
$executor->query(
295+
new Query($name, Message::TYPE_AAAA, Message::CLASS_IN)
296+
)->then(function (Message $message) {
297+
foreach ($message->answers as $answer) {
298+
echo 'IPv6: ' . $answer->data . PHP_EOL;
299+
}
300+
}, 'printf');
301+
302+
$loop->run();
303+
```
304+
305+
See also [example #92](examples).
306+
307+
Note that this executor does not implement a timeout, so you will very likely
308+
want to use this in combination with a `TimeoutExecutor` like this:
309+
310+
```php
311+
$executor = new TimeoutExecutor(
312+
new TcpTransportExecutor($nameserver, $loop),
313+
3.0,
314+
$loop
315+
);
316+
```
317+
318+
Unlike the `UdpTransportExecutor`, this class uses a reliable TCP/IP
319+
transport, so you do not necessarily have to implement any retry logic.
320+
321+
Note that this executor is entirely async and as such allows you to execute
322+
any number of queries concurrently. You should probably limit the number of
323+
concurrent queries in your application or you're very likely going to face
324+
rate limitations and bans on the resolver end. For many common applications,
325+
you may want to avoid sending the same query multiple times when the first
326+
one is still pending, so you will likely want to use this in combination with
327+
a `CoopExecutor` like this:
328+
329+
```php
330+
$executor = new CoopExecutor(
331+
new TimeoutExecutor(
332+
new TcpTransportExecutor($nameserver, $loop),
333+
3.0,
334+
$loop
335+
)
336+
);
337+
```
338+
339+
> Internally, this class uses PHP's TCP/IP sockets and does not take advantage
340+
of [react/socket](https://github.com/reactphp/socket) purely for
341+
organizational reasons to avoid a cyclic dependency between the two
342+
packages. Higher-level components should take advantage of the Socket
343+
component instead of reimplementing this socket logic from scratch.
344+
279345
### HostsFileExecutor
280346

281347
Note that the above `UdpTransportExecutor` class always performs an actual DNS query.

examples/92-query-any.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
<?php
22

3+
// $ php examples/92-query-any.php mailbox.org
4+
// $ php examples/92-query-any.php _carddav._tcp.mailbox.org
5+
36
use React\Dns\Model\Message;
47
use React\Dns\Model\Record;
58
use React\Dns\Query\Query;
6-
use React\Dns\Query\UdpTransportExecutor;
9+
use React\Dns\Query\TcpTransportExecutor;
710
use React\EventLoop\Factory;
811

912
require __DIR__ . '/../vendor/autoload.php';
1013

1114
$loop = Factory::create();
12-
$executor = new UdpTransportExecutor('8.8.8.8:53', $loop);
15+
$executor = new TcpTransportExecutor('8.8.8.8:53', $loop);
1316

1417
$name = isset($argv[1]) ? $argv[1] : 'google.com';
1518

src/Query/TcpTransportExecutor.php

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
<?php
2+
3+
namespace React\Dns\Query;
4+
5+
use React\Dns\Model\Message;
6+
use React\Dns\Protocol\BinaryDumper;
7+
use React\Dns\Protocol\Parser;
8+
use React\EventLoop\LoopInterface;
9+
use React\Promise\Deferred;
10+
11+
/**
12+
* Send DNS queries over a TCP/IP stream transport.
13+
*
14+
* This is one of the main classes that send a DNS query to your DNS server.
15+
*
16+
* For more advanced usages one can utilize this class directly.
17+
* The following example looks up the `IPv6` address for `reactphp.org`.
18+
*
19+
* ```php
20+
* $loop = Factory::create();
21+
* $executor = new TcpTransportExecutor('8.8.8.8:53', $loop);
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+
* $loop->run();
32+
* ```
33+
*
34+
* See also [example #92](examples).
35+
*
36+
* Note that this executor does not implement a timeout, so you will very likely
37+
* want to use this in combination with a `TimeoutExecutor` like this:
38+
*
39+
* ```php
40+
* $executor = new TimeoutExecutor(
41+
* new TcpTransportExecutor($nameserver, $loop),
42+
* 3.0,
43+
* $loop
44+
* );
45+
* ```
46+
*
47+
* Unlike the `UdpTransportExecutor`, this class uses a reliable TCP/IP
48+
* transport, so you do not necessarily have to implement any retry logic.
49+
*
50+
* Note that this executor is entirely async and as such allows you to execute
51+
* any number of queries concurrently. You should probably limit the number of
52+
* concurrent queries in your application or you're very likely going to face
53+
* rate limitations and bans on the resolver end. For many common applications,
54+
* you may want to avoid sending the same query multiple times when the first
55+
* one is still pending, so you will likely want to use this in combination with
56+
* a `CoopExecutor` like this:
57+
*
58+
* ```php
59+
* $executor = new CoopExecutor(
60+
* new TimeoutExecutor(
61+
* new TcpTransportExecutor($nameserver, $loop),
62+
* 3.0,
63+
* $loop
64+
* )
65+
* );
66+
* ```
67+
*
68+
* > Internally, this class uses PHP's TCP/IP sockets and does not take advantage
69+
* of [react/socket](https://github.com/reactphp/socket) purely for
70+
* organizational reasons to avoid a cyclic dependency between the two
71+
* packages. Higher-level components should take advantage of the Socket
72+
* component instead of reimplementing this socket logic from scratch.
73+
*/
74+
class TcpTransportExecutor implements ExecutorInterface
75+
{
76+
private $nameserver;
77+
private $loop;
78+
private $parser;
79+
private $dumper;
80+
81+
/**
82+
* @param string $nameserver
83+
* @param LoopInterface $loop
84+
*/
85+
public function __construct($nameserver, LoopInterface $loop)
86+
{
87+
if (\strpos($nameserver, '[') === false && \substr_count($nameserver, ':') >= 2) {
88+
// several colons, but not enclosed in square brackets => enclose IPv6 address in square brackets
89+
$nameserver = '[' . $nameserver . ']';
90+
}
91+
92+
$parts = \parse_url('tcp://' . $nameserver);
93+
if (!isset($parts['scheme'], $parts['host']) || !\filter_var(\trim($parts['host'], '[]'), \FILTER_VALIDATE_IP)) {
94+
throw new \InvalidArgumentException('Invalid nameserver address given');
95+
}
96+
97+
$this->nameserver = $parts['host'] . ':' . (isset($parts['port']) ? $parts['port'] : 53);
98+
$this->loop = $loop;
99+
$this->parser = new Parser();
100+
$this->dumper = new BinaryDumper();
101+
}
102+
103+
public function query(Query $query)
104+
{
105+
$request = Message::createRequestForQuery($query);
106+
$queryData = $this->dumper->toBinary($request);
107+
$length = \strlen($queryData);
108+
if ($length > 0xffff) {
109+
return \React\Promise\reject(new \RuntimeException(
110+
'DNS query for ' . $query->name . ' failed: Query too large for TCP transport'
111+
));
112+
}
113+
114+
$queryData = \pack('n', $length) . $queryData;
115+
116+
// create async TCP/IP connection (may take a while)
117+
$socket = @\stream_socket_client($this->nameserver, $errno, $errstr, 0, \STREAM_CLIENT_CONNECT | \STREAM_CLIENT_ASYNC_CONNECT);
118+
if ($socket === false) {
119+
return \React\Promise\reject(new \RuntimeException(
120+
'DNS query for ' . $query->name . ' failed: Unable to connect to DNS server (' . $errstr . ')',
121+
$errno
122+
));
123+
}
124+
125+
$loop = $this->loop;
126+
$deferred = new Deferred(function () use ($loop, $socket, $query) {
127+
// cancellation should remove socket from loop and close socket
128+
$loop->removeReadStream($socket);
129+
$loop->removeWriteStream($socket);
130+
\fclose($socket);
131+
132+
throw new CancellationException('DNS query for ' . $query->name . ' has been cancelled');
133+
});
134+
135+
// set socket to non-blocking and wait for it to become writable (connection success/rejected)
136+
\stream_set_blocking($socket, false);
137+
$loop->addWriteStream($socket, function ($socket) use ($loop, $query, $queryData, $deferred) {
138+
$loop->removeWriteStream($socket);
139+
$name = @\stream_socket_get_name($socket, true);
140+
if ($name === false) {
141+
$loop->removeReadStream($socket);
142+
@\fclose($socket);
143+
$deferred->reject(new \RuntimeException(
144+
'DNS query for ' . $query->name . ' failed: Connection to DNS server rejected'
145+
));
146+
return;
147+
}
148+
149+
$written = @\fwrite($socket, $queryData);
150+
if ($written !== \strlen($queryData)) {
151+
$loop->removeReadStream($socket);
152+
\fclose($socket);
153+
$deferred->reject(new \RuntimeException(
154+
'DNS query for ' . $query->name . ' failed: Unable to write DNS query message in one chunk'
155+
));
156+
}
157+
});
158+
159+
$buffer = '';
160+
$parser = $this->parser;
161+
$loop->addReadStream($socket, function ($socket) use (&$buffer, $loop, $deferred, $query, $parser, $request) {
162+
// read one chunk of data from the DNS server
163+
// any error is fatal, this is a stream of TCP/IP data
164+
$chunk = @\fread($socket, 65536);
165+
if ($chunk === false || $chunk === '') {
166+
$loop->removeReadStream($socket);
167+
\fclose($socket);
168+
$deferred->reject(new \RuntimeException(
169+
'DNS query for ' . $query->name . ' failed: Connection to DNS server lost'
170+
));
171+
return;
172+
}
173+
174+
// reassemble complete message by concatenating all chunks.
175+
// response message header contains at least 12 bytes
176+
$buffer .= $chunk;
177+
if (!isset($buffer[11])) {
178+
return;
179+
}
180+
181+
// read response message length from first 2 bytes and ensure we have length + data in buffer
182+
list(, $length) = \unpack('n', $buffer);
183+
if (!isset($buffer[$length + 1])) {
184+
return;
185+
}
186+
187+
// we only react to the first complete message, so remove socket from loop and close
188+
$loop->removeReadStream($socket);
189+
\fclose($socket);
190+
$data = \substr($buffer, 2, $length);
191+
$buffer = '';
192+
193+
try {
194+
$response = $parser->parseMessage($data);
195+
} catch (\Exception $e) {
196+
// reject if we received an invalid message from remote server
197+
$deferred->reject(new \RuntimeException(
198+
'DNS query for ' . $query->name . ' failed: Invalid message received from DNS server',
199+
0,
200+
$e
201+
));
202+
return;
203+
}
204+
205+
// reject if we received an unexpected response ID or truncated response
206+
if ($response->id !== $request->id || $response->tc) {
207+
$deferred->reject(new \RuntimeException(
208+
'DNS query for ' . $query->name . ' failed: Invalid response message received from DNS server'
209+
));
210+
return;
211+
}
212+
213+
$deferred->resolve($response);
214+
});
215+
216+
return $deferred->promise();
217+
}
218+
}

0 commit comments

Comments
 (0)