Skip to content

Commit

Permalink
Enable support for DNS over TLS
Browse files Browse the repository at this point in the history
  • Loading branch information
lucasnetau committed Aug 4, 2022
1 parent 2bc106d commit 7ba22c2
Show file tree
Hide file tree
Showing 5 changed files with 499 additions and 24 deletions.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ easily be used to create a DNS server.
* [Advanced usage](#advanced-usage)
* [UdpTransportExecutor](#udptransportexecutor)
* [TcpTransportExecutor](#tcptransportexecutor)
* [DNS over TLS](#dns-over-tls-dot)
* [SelectiveTransportExecutor](#selectivetransportexecutor)
* [HostsFileExecutor](#hostsfileexecutor)
* [Install](#install)
Expand Down Expand Up @@ -336,6 +337,27 @@ $executor = new CoopExecutor(
packages. Higher-level components should take advantage of the Socket
component instead of reimplementing this socket logic from scratch.

#### DNS over TLS (DoT)
DoT provides secure DNS lookups over Transport Layer Security (TLS).
The tls:// scheme must be provided when configuring nameservers to
enable DoT communication to a TLS supporting DNS server.
The port 853 is used by default.

```php
$executor = new TcpTransportExecutor('tls://8.8.8.8');
````

> Note: To ensure security and privacy, DoT resolvers typically only support
TLS 1.2 and above. DoT is not supported on legacy PHP < 5.6 and HHVM

##### TLS Configuration
[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`.

```php
// Verify that the 8.8.8.8 resolver's certificate CN matches dns.google
$executor = new TcpTransportExecutor('tls://8.8.8.8?ssl[peer_name]=dns.google');
````

### SelectiveTransportExecutor

The `SelectiveTransportExecutor` class can be used to
Expand Down
168 changes: 146 additions & 22 deletions src/Query/TcpTransportExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use React\Dns\Protocol\Parser;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\Promise\Deferred;
use React\Promise;

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

/**
* @var Deferred[]
* @var Promise\Deferred[]
*/
private $pending = array();

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

/** @var bool */
private $tls = false;

/** @var bool */
private $cryptoEnabled = false;

/**
* Maximum idle time when socket is current unused (i.e. no pending queries outstanding)
*
Expand Down Expand Up @@ -130,6 +139,8 @@ class TcpTransportExecutor implements ExecutorInterface
/** @var string */
private $readChunk = 0xffff;

private $connection_parameters = array();

/**
* @param string $nameserver
* @param ?LoopInterface $loop
Expand All @@ -142,11 +153,17 @@ public function __construct($nameserver, LoopInterface $loop = null)
}

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

$this->nameserver = 'tcp://' . $parts['host'] . ':' . (isset($parts['port']) ? $parts['port'] : 53);
//Parse any connection parameters to be supplied to stream_context_create()
if (isset($parts['query'])) {
parse_str($parts['query'], $this->connection_parameters);
}

$this->tls = $parts['scheme'] === 'tls';
$this->nameserver = 'tcp://' . $parts['host'] . ':' . (isset($parts['port']) ? $parts['port'] : ($this->tls ? 853 : 53));
$this->loop = $loop ?: Loop::get();
$this->parser = new Parser();
$this->dumper = new BinaryDumper();
Expand All @@ -164,18 +181,36 @@ public function query(Query $query)
$queryData = $this->dumper->toBinary($request);
$length = \strlen($queryData);
if ($length > 0xffff) {
return \React\Promise\reject(new \RuntimeException(
return Promise\reject(new \RuntimeException(
'DNS query for ' . $query->describe() . ' failed: Query too large for TCP transport'
));
}

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

if ($this->socket === null) {
//Setup TLS context if requested
$cOption = array();
if ($this->tls) {
if (!\function_exists('stream_socket_enable_crypto') || defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) {
return Promise\reject(new \BadMethodCallException('Encryption not supported on your platform (HHVM < 3.8 or PHP < 5.6?)')); // @codeCoverageIgnore
}
// Setup sane defaults for SSL to ensure secure connection to the DNS server
$cOption['ssl'] = array(
'verify_peer' => true,
'verify_peer_name' => true,
'allow_self_signed' => false,
);
}
$cOption = array_merge($cOption, $this->connection_parameters);
if (empty($cOption)) {
$cOption = null;
}
$context = stream_context_create($cOption);
// create async TCP/IP connection (may take a while)
$socket = @\stream_socket_client($this->nameserver, $errno, $errstr, 0, \STREAM_CLIENT_CONNECT | \STREAM_CLIENT_ASYNC_CONNECT);
$socket = @\stream_socket_client($this->nameserver, $errno, $errstr, 0, \STREAM_CLIENT_CONNECT | \STREAM_CLIENT_ASYNC_CONNECT, $context);
if ($socket === false) {
return \React\Promise\reject(new \RuntimeException(
return Promise\reject(new \RuntimeException(
'DNS query for ' . $query->describe() . ' failed: Unable to connect to DNS server ' . $this->nameserver . ' (' . $errstr . ')',
$errno
));
Expand Down Expand Up @@ -203,7 +238,7 @@ public function query(Query $query)

$names =& $this->names;
$that = $this;
$deferred = new Deferred(function () use ($that, &$names, $request) {
$deferred = new Promise\Deferred(function () use ($that, &$names, $request) {
// remove from list of pending names, but remember pending query
$name = $names[$request->id];
unset($names[$request->id]);
Expand All @@ -223,9 +258,51 @@ public function query(Query $query)
*/
public function handleWritable()
{
if ($this->tls && false === $this->cryptoEnabled) {
$error = null;
\set_error_handler(function ($_, $errstr) use (&$error) {
$error = \str_replace(array("\r", "\n"), ' ', $errstr);

// remove useless function name from error message
if (($pos = \strpos($error, "): ")) !== false) {
$error = \substr($error, $pos + 3);
}
});

$method = \STREAM_CRYPTO_METHOD_TLS_CLIENT;
if (\PHP_VERSION_ID < 70200 && \PHP_VERSION_ID >= 50600) {
$method |= \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; // @codeCoverageIgnore
}

$result = \stream_socket_enable_crypto($this->socket, true, $method);

\restore_error_handler();

if (true === $result) {
$this->cryptoEnabled = true;
} elseif (false === $result) {
if (\feof($this->socket) || $error === null) {
// EOF or failed without error => connection closed during handshake
$this->closeError(
'Connection lost during TLS handshake (ECONNRESET)',
\defined('SOCKET_ECONNRESET') ? \SOCKET_ECONNRESET : 104
);
} else {
// handshake failed with error message
$this->closeError(
$error
);
}
return;
} else {
// need more data, will retry
return;
}
}

if ($this->readPending === false) {
$name = @\stream_socket_get_name($this->socket, true);
if ($name === false) {
if (!is_string($name)) { //PHP: false, HHVM: null on error
// Connection failed? Check socket error if available for underlying errno/errstr.
// @codeCoverageIgnoreStart
if (\function_exists('socket_import_stream')) {
Expand All @@ -247,7 +324,7 @@ public function handleWritable()
}

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

$written = \fwrite($this->socket, $this->writeBuffer);

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

if ($written === false || $written === 0) {
$this->closeError(
'Unable to send query to DNS server ' . $this->nameserver . ' (' . $errstr . ')',
$errno
);
return;
// 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].
// Ignore non-fatal warnings if *some* data could be sent.
// Any hard (permanent) error will fail to send any data at all.
// Sending excessive amounts of data will only flush *some* data and then
// report a temporary error (EAGAIN) which we do not raise here in order
// to keep the stream open for further tries to write.
// Should this turn out to be a permanent error later, it will eventually
// send *nothing* and we can detect this.
if (($written === false || $written === 0)) {
$name = @\stream_socket_get_name($this->socket, true);
if (!is_string($name) || $errstr !== null) {
\restore_error_handler();
$this->closeError(
'Unable to send query to DNS server ' . $this->nameserver . ' (' . $errstr . ')',
$errno
);
return;
}
}

\restore_error_handler();

if (isset($this->writeBuffer[$written])) {
$this->writeBuffer = \substr($this->writeBuffer, $written);
} else {
Expand All @@ -282,9 +383,30 @@ public function handleWritable()
*/
public function handleRead()
{
// @codeCoverageIgnoreStart
if (null === $this->socket) {
$this->closeError('Connection to DNS server ' . $this->nameserver . ' lost');
return;
}
// @codeCoverageIgnoreEnd

// read one chunk of data from the DNS server
// any error is fatal, this is a stream of TCP/IP data
$chunk = @\fread($this->socket, $this->readChunk);
// PHP < 7.3.3 (and PHP < 7.2.15) suffers from a bug where feof() might
// block with 100% CPU usage on fragmented TLS records.
// We try to work around this by always consuming the complete receive
// buffer at once to avoid stale data in TLS buffers. This is known to
// work around high CPU usage for well-behaving peers, but this may
// cause very large data chunks for high throughput scenarios. The buggy
// behavior can still be triggered due to network I/O buffers or
// malicious peers on affected versions, upgrading is highly recommended.
// @link https://bugs.php.net/bug.php?id=77390
if ($this->tls && (\PHP_VERSION_ID < 70215 || (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70303))) {
$chunk = @\stream_get_contents($this->socket, -1); // @codeCoverageIgnore
} else {
$chunk = @\stream_get_contents($this->socket, $this->readChunk);
}

if ($chunk === false || $chunk === '') {
$this->closeError('Connection to DNS server ' . $this->nameserver . ' lost');
return;
Expand Down Expand Up @@ -351,8 +473,10 @@ public function closeError($reason, $code = 0)
$this->idleTimer = null;
}

@\fclose($this->socket);
$this->socket = null;
if (null !== $this->socket) {
@\fclose($this->socket);
$this->socket = null;
}

foreach ($this->names as $id => $name) {
$this->pending[$id]->reject(new \RuntimeException(
Expand Down
2 changes: 1 addition & 1 deletion src/Resolver/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ private function createSingleExecutor($nameserver, LoopInterface $loop)
{
$parts = \parse_url($nameserver);

if (isset($parts['scheme']) && $parts['scheme'] === 'tcp') {
if (isset($parts['scheme']) && in_array($parts['scheme'], array('tcp','tls'), true)) {
$executor = $this->createTcpExecutor($nameserver, $loop);
} elseif (isset($parts['scheme']) && $parts['scheme'] === 'udp') {
$executor = $this->createUdpExecutor($nameserver, $loop);
Expand Down
60 changes: 60 additions & 0 deletions tests/FunctionalResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,66 @@ public function testResolveGoogleOverTcpResolves()
$this->loop->run();
}

/**
* @group internet
*/
public function testResolveGoogleOverTlsResolves()
{
if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) {
$this->markTestSkipped('DNS over TLS not supported on legacy PHP');
}

$factory = new Factory();
$this->resolver = $factory->create('tls://8.8.8.8?socket[tcp_nodelay]=true', $this->loop);

$promise = $this->resolver->resolve('google.com');
$promise->then($this->expectCallableOnce(), $this->expectCallableNever());

$this->loop->run();
}

/**
* @group internet
*/
public function testAttemptTlsOnNonTlsPortRejects()
{
if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) {
$this->markTestSkipped('DNS over TLS not supported on legacy PHP');
}

$factory = new Factory();
$this->resolver = $factory->create('tls://8.8.8.8:53', $this->loop);

$promise = $this->resolver->resolve('google.com');
$promise->then($this->expectCallableNever(), $this->expectCallableOnce());

$this->loop->run();
}

/**
* @group internet
*/
public function testUnsupportedLegacyPhpOverTlsRejectsWithBadMethodCall()
{
if (!(defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600)) {
$this->markTestSkipped('Tests not relevant to recent PHP versions');
}

$factory = new Factory();
$this->resolver = $factory->create('tls://8.8.8.8', $this->loop);

$promise = $this->resolver->resolve('google.com');
$exception = null;
$promise->then($this->expectCallableNever(), function ($reason) use (&$exception) {
$exception = $reason;
});

/** @var \BadMethodCallException $exception */
$this->assertInstanceOf('BadMethodCallException', $exception);

$this->loop->run();
}

/**
* @group internet
*/
Expand Down
Loading

0 comments on commit 7ba22c2

Please sign in to comment.