Skip to content

Commit 7ba22c2

Browse files
committed
Enable support for DNS over TLS
https://tools.ietf.org/html/rfc7858
1 parent 2bc106d commit 7ba22c2

File tree

5 files changed

+499
-24
lines changed

5 files changed

+499
-24
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ easily be used to create a DNS server.
2020
* [Advanced usage](#advanced-usage)
2121
* [UdpTransportExecutor](#udptransportexecutor)
2222
* [TcpTransportExecutor](#tcptransportexecutor)
23+
* [DNS over TLS](#dns-over-tls-dot)
2324
* [SelectiveTransportExecutor](#selectivetransportexecutor)
2425
* [HostsFileExecutor](#hostsfileexecutor)
2526
* [Install](#install)
@@ -336,6 +337,27 @@ $executor = new CoopExecutor(
336337
packages. Higher-level components should take advantage of the Socket
337338
component instead of reimplementing this socket logic from scratch.
338339

340+
#### DNS over TLS (DoT)
341+
DoT provides secure DNS lookups over Transport Layer Security (TLS).
342+
The tls:// scheme must be provided when configuring nameservers to
343+
enable DoT communication to a TLS supporting DNS server.
344+
The port 853 is used by default.
345+
346+
```php
347+
$executor = new TcpTransportExecutor('tls://8.8.8.8');
348+
````
349+
350+
> Note: To ensure security and privacy, DoT resolvers typically only support
351+
TLS 1.2 and above. DoT is not supported on legacy PHP < 5.6 and HHVM
352+
353+
##### TLS Configuration
354+
[SSL Context parameters](https://www.php.net/manual/en/context.ssl.php) can be set appending passing query parameters to the nameserver URI in the format `wrapper[parameter]=value`.
355+
356+
```php
357+
// Verify that the 8.8.8.8 resolver's certificate CN matches dns.google
358+
$executor = new TcpTransportExecutor('tls://8.8.8.8?ssl[peer_name]=dns.google');
359+
````
360+
339361
### SelectiveTransportExecutor
340362

341363
The `SelectiveTransportExecutor` class can be used to

src/Query/TcpTransportExecutor.php

Lines changed: 146 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
use React\Dns\Protocol\Parser;
88
use React\EventLoop\Loop;
99
use React\EventLoop\LoopInterface;
10-
use React\Promise\Deferred;
10+
use React\Promise;
1111

1212
/**
1313
* Send DNS queries over a TCP/IP stream transport.
@@ -74,6 +74,9 @@
7474
* organizational reasons to avoid a cyclic dependency between the two
7575
* packages. Higher-level components should take advantage of the Socket
7676
* component instead of reimplementing this socket logic from scratch.
77+
*
78+
* Support for DNS over TLS can be enabled via specifying the nameserver with scheme tls://
79+
* @link https://tools.ietf.org/html/rfc7858
7780
*/
7881
class TcpTransportExecutor implements ExecutorInterface
7982
{
@@ -88,7 +91,7 @@ class TcpTransportExecutor implements ExecutorInterface
8891
private $socket;
8992

9093
/**
91-
* @var Deferred[]
94+
* @var Promise\Deferred[]
9295
*/
9396
private $pending = array();
9497

@@ -97,6 +100,12 @@ class TcpTransportExecutor implements ExecutorInterface
97100
*/
98101
private $names = array();
99102

103+
/** @var bool */
104+
private $tls = false;
105+
106+
/** @var bool */
107+
private $cryptoEnabled = false;
108+
100109
/**
101110
* Maximum idle time when socket is current unused (i.e. no pending queries outstanding)
102111
*
@@ -130,6 +139,8 @@ class TcpTransportExecutor implements ExecutorInterface
130139
/** @var string */
131140
private $readChunk = 0xffff;
132141

142+
private $connection_parameters = array();
143+
133144
/**
134145
* @param string $nameserver
135146
* @param ?LoopInterface $loop
@@ -142,11 +153,17 @@ public function __construct($nameserver, LoopInterface $loop = null)
142153
}
143154

144155
$parts = \parse_url((\strpos($nameserver, '://') === false ? 'tcp://' : '') . $nameserver);
145-
if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'tcp' || @\inet_pton(\trim($parts['host'], '[]')) === false) {
156+
if (!isset($parts['scheme'], $parts['host']) || !in_array($parts['scheme'], array('tcp','tls'), true) || @\inet_pton(\trim($parts['host'], '[]')) === false) {
146157
throw new \InvalidArgumentException('Invalid nameserver address given');
147158
}
148159

149-
$this->nameserver = 'tcp://' . $parts['host'] . ':' . (isset($parts['port']) ? $parts['port'] : 53);
160+
//Parse any connection parameters to be supplied to stream_context_create()
161+
if (isset($parts['query'])) {
162+
parse_str($parts['query'], $this->connection_parameters);
163+
}
164+
165+
$this->tls = $parts['scheme'] === 'tls';
166+
$this->nameserver = 'tcp://' . $parts['host'] . ':' . (isset($parts['port']) ? $parts['port'] : ($this->tls ? 853 : 53));
150167
$this->loop = $loop ?: Loop::get();
151168
$this->parser = new Parser();
152169
$this->dumper = new BinaryDumper();
@@ -164,18 +181,36 @@ public function query(Query $query)
164181
$queryData = $this->dumper->toBinary($request);
165182
$length = \strlen($queryData);
166183
if ($length > 0xffff) {
167-
return \React\Promise\reject(new \RuntimeException(
184+
return Promise\reject(new \RuntimeException(
168185
'DNS query for ' . $query->describe() . ' failed: Query too large for TCP transport'
169186
));
170187
}
171188

172189
$queryData = \pack('n', $length) . $queryData;
173190

174191
if ($this->socket === null) {
192+
//Setup TLS context if requested
193+
$cOption = array();
194+
if ($this->tls) {
195+
if (!\function_exists('stream_socket_enable_crypto') || defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) {
196+
return Promise\reject(new \BadMethodCallException('Encryption not supported on your platform (HHVM < 3.8 or PHP < 5.6?)')); // @codeCoverageIgnore
197+
}
198+
// Setup sane defaults for SSL to ensure secure connection to the DNS server
199+
$cOption['ssl'] = array(
200+
'verify_peer' => true,
201+
'verify_peer_name' => true,
202+
'allow_self_signed' => false,
203+
);
204+
}
205+
$cOption = array_merge($cOption, $this->connection_parameters);
206+
if (empty($cOption)) {
207+
$cOption = null;
208+
}
209+
$context = stream_context_create($cOption);
175210
// create async TCP/IP connection (may take a while)
176-
$socket = @\stream_socket_client($this->nameserver, $errno, $errstr, 0, \STREAM_CLIENT_CONNECT | \STREAM_CLIENT_ASYNC_CONNECT);
211+
$socket = @\stream_socket_client($this->nameserver, $errno, $errstr, 0, \STREAM_CLIENT_CONNECT | \STREAM_CLIENT_ASYNC_CONNECT, $context);
177212
if ($socket === false) {
178-
return \React\Promise\reject(new \RuntimeException(
213+
return Promise\reject(new \RuntimeException(
179214
'DNS query for ' . $query->describe() . ' failed: Unable to connect to DNS server ' . $this->nameserver . ' (' . $errstr . ')',
180215
$errno
181216
));
@@ -203,7 +238,7 @@ public function query(Query $query)
203238

204239
$names =& $this->names;
205240
$that = $this;
206-
$deferred = new Deferred(function () use ($that, &$names, $request) {
241+
$deferred = new Promise\Deferred(function () use ($that, &$names, $request) {
207242
// remove from list of pending names, but remember pending query
208243
$name = $names[$request->id];
209244
unset($names[$request->id]);
@@ -223,9 +258,51 @@ public function query(Query $query)
223258
*/
224259
public function handleWritable()
225260
{
261+
if ($this->tls && false === $this->cryptoEnabled) {
262+
$error = null;
263+
\set_error_handler(function ($_, $errstr) use (&$error) {
264+
$error = \str_replace(array("\r", "\n"), ' ', $errstr);
265+
266+
// remove useless function name from error message
267+
if (($pos = \strpos($error, "): ")) !== false) {
268+
$error = \substr($error, $pos + 3);
269+
}
270+
});
271+
272+
$method = \STREAM_CRYPTO_METHOD_TLS_CLIENT;
273+
if (\PHP_VERSION_ID < 70200 && \PHP_VERSION_ID >= 50600) {
274+
$method |= \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; // @codeCoverageIgnore
275+
}
276+
277+
$result = \stream_socket_enable_crypto($this->socket, true, $method);
278+
279+
\restore_error_handler();
280+
281+
if (true === $result) {
282+
$this->cryptoEnabled = true;
283+
} elseif (false === $result) {
284+
if (\feof($this->socket) || $error === null) {
285+
// EOF or failed without error => connection closed during handshake
286+
$this->closeError(
287+
'Connection lost during TLS handshake (ECONNRESET)',
288+
\defined('SOCKET_ECONNRESET') ? \SOCKET_ECONNRESET : 104
289+
);
290+
} else {
291+
// handshake failed with error message
292+
$this->closeError(
293+
$error
294+
);
295+
}
296+
return;
297+
} else {
298+
// need more data, will retry
299+
return;
300+
}
301+
}
302+
226303
if ($this->readPending === false) {
227304
$name = @\stream_socket_get_name($this->socket, true);
228-
if ($name === false) {
305+
if (!is_string($name)) { //PHP: false, HHVM: null on error
229306
// Connection failed? Check socket error if available for underlying errno/errstr.
230307
// @codeCoverageIgnoreStart
231308
if (\function_exists('socket_import_stream')) {
@@ -247,7 +324,7 @@ public function handleWritable()
247324
}
248325

249326
$errno = 0;
250-
$errstr = '';
327+
$errstr = null;
251328
\set_error_handler(function ($_, $error) use (&$errno, &$errstr) {
252329
// Match errstr from PHP's warning message.
253330
// fwrite(): Send of 327712 bytes failed with errno=32 Broken pipe
@@ -256,18 +333,42 @@ public function handleWritable()
256333
$errstr = isset($m[2]) ? $m[2] : $error;
257334
});
258335

259-
$written = \fwrite($this->socket, $this->writeBuffer);
260-
261-
\restore_error_handler();
336+
// PHP < 7.1.4 (and PHP < 7.0.18) suffers from a bug when writing big
337+
// chunks of data over TLS streams at once.
338+
// We try to work around this by limiting the write chunk size to 8192
339+
// bytes for older PHP versions only.
340+
// This is only a work-around and has a noticable performance penalty on
341+
// affected versions. Please update your PHP version.
342+
// This applies only to configured TLS connections
343+
// See https://github.com/reactphp/socket/issues/105
344+
if ($this->tls && (\PHP_VERSION_ID < 70018 || (\PHP_VERSION_ID >= 70100 && \PHP_VERSION_ID < 70104))) {
345+
$written = \fwrite($this->socket, $this->writeBuffer, 8192); // @codeCoverageIgnore
346+
} else {
347+
$written = \fwrite($this->socket, $this->writeBuffer);
348+
}
262349

263-
if ($written === false || $written === 0) {
264-
$this->closeError(
265-
'Unable to send query to DNS server ' . $this->nameserver . ' (' . $errstr . ')',
266-
$errno
267-
);
268-
return;
350+
// Only report errors if *nothing* could be sent and an error has been raised, or we are unable to retrieve the remote socket name (connection dead) [HHVM].
351+
// Ignore non-fatal warnings if *some* data could be sent.
352+
// Any hard (permanent) error will fail to send any data at all.
353+
// Sending excessive amounts of data will only flush *some* data and then
354+
// report a temporary error (EAGAIN) which we do not raise here in order
355+
// to keep the stream open for further tries to write.
356+
// Should this turn out to be a permanent error later, it will eventually
357+
// send *nothing* and we can detect this.
358+
if (($written === false || $written === 0)) {
359+
$name = @\stream_socket_get_name($this->socket, true);
360+
if (!is_string($name) || $errstr !== null) {
361+
\restore_error_handler();
362+
$this->closeError(
363+
'Unable to send query to DNS server ' . $this->nameserver . ' (' . $errstr . ')',
364+
$errno
365+
);
366+
return;
367+
}
269368
}
270369

370+
\restore_error_handler();
371+
271372
if (isset($this->writeBuffer[$written])) {
272373
$this->writeBuffer = \substr($this->writeBuffer, $written);
273374
} else {
@@ -282,9 +383,30 @@ public function handleWritable()
282383
*/
283384
public function handleRead()
284385
{
386+
// @codeCoverageIgnoreStart
387+
if (null === $this->socket) {
388+
$this->closeError('Connection to DNS server ' . $this->nameserver . ' lost');
389+
return;
390+
}
391+
// @codeCoverageIgnoreEnd
392+
285393
// read one chunk of data from the DNS server
286394
// any error is fatal, this is a stream of TCP/IP data
287-
$chunk = @\fread($this->socket, $this->readChunk);
395+
// PHP < 7.3.3 (and PHP < 7.2.15) suffers from a bug where feof() might
396+
// block with 100% CPU usage on fragmented TLS records.
397+
// We try to work around this by always consuming the complete receive
398+
// buffer at once to avoid stale data in TLS buffers. This is known to
399+
// work around high CPU usage for well-behaving peers, but this may
400+
// cause very large data chunks for high throughput scenarios. The buggy
401+
// behavior can still be triggered due to network I/O buffers or
402+
// malicious peers on affected versions, upgrading is highly recommended.
403+
// @link https://bugs.php.net/bug.php?id=77390
404+
if ($this->tls && (\PHP_VERSION_ID < 70215 || (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70303))) {
405+
$chunk = @\stream_get_contents($this->socket, -1); // @codeCoverageIgnore
406+
} else {
407+
$chunk = @\stream_get_contents($this->socket, $this->readChunk);
408+
}
409+
288410
if ($chunk === false || $chunk === '') {
289411
$this->closeError('Connection to DNS server ' . $this->nameserver . ' lost');
290412
return;
@@ -351,8 +473,10 @@ public function closeError($reason, $code = 0)
351473
$this->idleTimer = null;
352474
}
353475

354-
@\fclose($this->socket);
355-
$this->socket = null;
476+
if (null !== $this->socket) {
477+
@\fclose($this->socket);
478+
$this->socket = null;
479+
}
356480

357481
foreach ($this->names as $id => $name) {
358482
$this->pending[$id]->reject(new \RuntimeException(

src/Resolver/Factory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ private function createSingleExecutor($nameserver, LoopInterface $loop)
165165
{
166166
$parts = \parse_url($nameserver);
167167

168-
if (isset($parts['scheme']) && $parts['scheme'] === 'tcp') {
168+
if (isset($parts['scheme']) && in_array($parts['scheme'], array('tcp','tls'), true)) {
169169
$executor = $this->createTcpExecutor($nameserver, $loop);
170170
} elseif (isset($parts['scheme']) && $parts['scheme'] === 'udp') {
171171
$executor = $this->createUdpExecutor($nameserver, $loop);

tests/FunctionalResolverTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,66 @@ public function testResolveGoogleOverTcpResolves()
7575
$this->loop->run();
7676
}
7777

78+
/**
79+
* @group internet
80+
*/
81+
public function testResolveGoogleOverTlsResolves()
82+
{
83+
if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) {
84+
$this->markTestSkipped('DNS over TLS not supported on legacy PHP');
85+
}
86+
87+
$factory = new Factory();
88+
$this->resolver = $factory->create('tls://8.8.8.8?socket[tcp_nodelay]=true', $this->loop);
89+
90+
$promise = $this->resolver->resolve('google.com');
91+
$promise->then($this->expectCallableOnce(), $this->expectCallableNever());
92+
93+
$this->loop->run();
94+
}
95+
96+
/**
97+
* @group internet
98+
*/
99+
public function testAttemptTlsOnNonTlsPortRejects()
100+
{
101+
if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) {
102+
$this->markTestSkipped('DNS over TLS not supported on legacy PHP');
103+
}
104+
105+
$factory = new Factory();
106+
$this->resolver = $factory->create('tls://8.8.8.8:53', $this->loop);
107+
108+
$promise = $this->resolver->resolve('google.com');
109+
$promise->then($this->expectCallableNever(), $this->expectCallableOnce());
110+
111+
$this->loop->run();
112+
}
113+
114+
/**
115+
* @group internet
116+
*/
117+
public function testUnsupportedLegacyPhpOverTlsRejectsWithBadMethodCall()
118+
{
119+
if (!(defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600)) {
120+
$this->markTestSkipped('Tests not relevant to recent PHP versions');
121+
}
122+
123+
$factory = new Factory();
124+
$this->resolver = $factory->create('tls://8.8.8.8', $this->loop);
125+
126+
$promise = $this->resolver->resolve('google.com');
127+
$exception = null;
128+
$promise->then($this->expectCallableNever(), function ($reason) use (&$exception) {
129+
$exception = $reason;
130+
});
131+
132+
/** @var \BadMethodCallException $exception */
133+
$this->assertInstanceOf('BadMethodCallException', $exception);
134+
135+
$this->loop->run();
136+
}
137+
78138
/**
79139
* @group internet
80140
*/

0 commit comments

Comments
 (0)