diff --git a/README.md b/README.md index 06dc20eb..b8ade65f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ easily be used to create a DNS server. * [Advanced usage](#advanced-usage) * [UdpTransportExecutor](#udptransportexecutor) * [TcpTransportExecutor](#tcptransportexecutor) + * [TlsTransportExecutor](#tlstransportexecutor) * [SelectiveTransportExecutor](#selectivetransportexecutor) * [HostsFileExecutor](#hostsfileexecutor) * [Install](#install) @@ -336,6 +337,30 @@ $executor = new CoopExecutor( packages. Higher-level components should take advantage of the Socket component instead of reimplementing this socket logic from scratch. +### TlsTransportExecutor +The TlsTransportExecutor builds upon TcpTransportExecutor +providing support for 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 diff --git a/src/Query/TcpTransportExecutor.php b/src/Query/TcpTransportExecutor.php index bfaedbae..ec7badb0 100644 --- a/src/Query/TcpTransportExecutor.php +++ b/src/Query/TcpTransportExecutor.php @@ -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. @@ -74,6 +74,7 @@ * 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. + * */ class TcpTransportExecutor implements ExecutorInterface { @@ -85,10 +86,10 @@ class TcpTransportExecutor implements ExecutorInterface /** * @var ?resource */ - private $socket; + protected $socket; /** - * @var Deferred[] + * @var Promise\Deferred[] */ private $pending = array(); @@ -128,7 +129,13 @@ class TcpTransportExecutor implements ExecutorInterface private $readPending = false; /** @var string */ - private $readChunk = 0xffff; + protected $readChunk = 0xffff; + + /** @var null|int */ + protected $writeChunk = null; + + /** @var array Connection parameters to provide to stream_context_create */ + private $connectionParameters = array(); /** * @param string $nameserver @@ -146,6 +153,11 @@ public function __construct($nameserver, LoopInterface $loop = null) throw new \InvalidArgumentException('Invalid nameserver address given'); } + //Parse any connection parameters to be supplied to stream_context_create() + if (isset($parts['query'])) { + parse_str($parts['query'], $this->connectionParameters); + } + $this->nameserver = 'tcp://' . $parts['host'] . ':' . (isset($parts['port']) ? $parts['port'] : 53); $this->loop = $loop ?: Loop::get(); $this->parser = new Parser(); @@ -164,7 +176,7 @@ 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' )); } @@ -172,10 +184,12 @@ public function query(Query $query) $queryData = \pack('n', $length) . $queryData; if ($this->socket === null) { + //Setup stream context if requested ($options must be null if connectionParameters is an empty array + $context = stream_context_create((empty($this->connectionParameters) ? null : $this->connectionParameters)); // 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 )); @@ -183,7 +197,7 @@ public function query(Query $query) // set socket to non-blocking and wait for it to become writable (connection success/rejected) \stream_set_blocking($socket, false); - if (\function_exists('stream_set_chunk_size')) { + if ($this->readChunk !== -1 && \function_exists('stream_set_chunk_size')) { \stream_set_chunk_size($socket, $this->readChunk); // @codeCoverageIgnore } $this->socket = $socket; @@ -203,7 +217,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]); @@ -225,7 +239,7 @@ public function handleWritable() { 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')) { @@ -247,7 +261,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 @@ -256,18 +270,34 @@ public function handleWritable() $errstr = isset($m[2]) ? $m[2] : $error; }); - $written = \fwrite($this->socket, $this->writeBuffer); - - \restore_error_handler(); + if ($this->writeChunk !== null) { + $written = \fwrite($this->socket, $this->writeBuffer, $this->writeChunk); + } 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 { @@ -282,9 +312,15 @@ public function handleWritable() */ public function handleRead() { - // 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); + // @codeCoverageIgnoreStart + if (null === $this->socket) { + $this->closeError('Connection to DNS server ' . $this->nameserver . ' lost'); + return; + } + // @codeCoverageIgnoreEnd + + $chunk = @\stream_get_contents($this->socket, $this->readChunk); + if ($chunk === false || $chunk === '') { $this->closeError('Connection to DNS server ' . $this->nameserver . ' lost'); return; @@ -351,8 +387,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( diff --git a/src/Query/TlsTransportExecutor.php b/src/Query/TlsTransportExecutor.php new file mode 100644 index 00000000..67e927d7 --- /dev/null +++ b/src/Query/TlsTransportExecutor.php @@ -0,0 +1,191 @@ +query( + * new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) + * )->then(function (Message $message) { + * foreach ($message->answers as $answer) { + * echo 'IPv6: ' . $answer->data . PHP_EOL; + * } + * }, 'printf'); + * ``` + * + * See also [example #92](examples). + * + * Note that this executor does not implement a timeout, so you will very likely + * want to use this in combination with a `TimeoutExecutor` like this: + * + * ```php + * $executor = new TimeoutExecutor( + * new TcpTransportExecutor($nameserver), + * 3.0 + * ); + * ``` + * + * Unlike the `UdpTransportExecutor`, this class uses a reliable TCP/IP + * transport, so you do not necessarily have to implement any retry logic. + * + * Note that this executor is entirely async and as such allows you to execute + * queries concurrently. The first query will establish a TCP/IP+TLS socket + * connection to the DNS server which will be kept open for a short period. + * Additional queries will automatically reuse this existing socket connection + * to the DNS server, will pipeline multiple requests over this single + * connection and will keep an idle connection open for a short period. The + * initial TCP/IP+TLS connection overhead may incur a slight delay if you only send + * occasional queries – when sending a larger number of concurrent queries over + * an existing connection, it becomes increasingly more efficient and avoids + * creating many concurrent sockets like the UDP-based executor. You may still + * want to limit the number of (concurrent) queries in your application or you + * may be facing rate limitations and bans on the resolver end. For many common + * applications, you may want to avoid sending the same query multiple times + * when the first one is still pending, so you will likely want to use this in + * combination with a `CoopExecutor` like this: + * + * ```php + * $executor = new CoopExecutor( + * new TimeoutExecutor( + * new TlsTransportExecutor($nameserver), + * 3.0 + * ) + * ); + * ``` + * + * > Internally, this class uses PHP's TCP/IP sockets and does not take advantage + * of [react/socket](https://github.com/reactphp/socket) purely for + * 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 TlsTransportExecutor extends TcpTransportExecutor +{ + /** @var bool */ + private $cryptoEnabled = false; + + /** + * @param string $nameserver + * @param ?LoopInterface $loop + */ + public function __construct($nameserver, LoopInterface $loop = null) + { + if (!\function_exists('stream_socket_enable_crypto') || defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) { + throw new \RuntimeException('Encryption not supported on your platform (HHVM < 3.8 or PHP < 5.6?)'); // @codeCoverageIgnore + } + + $parsedNameserver = \parse_url((\strpos($nameserver, '://') === false ? 'tls://' : '') . $nameserver); + if ($parsedNameserver['scheme'] !== 'tls') { + throw new \InvalidArgumentException('Invalid nameserver address given'); + } + + // Setup sane defaults for SSL to ensure secure connection to the DNS server + $query = array(); + if (isset($parsedNameserver['query'])) { + \parse_str($parsedNameserver['query'], $query); + } + $query = array_merge(array( + 'ssl' => array( + 'verify_peer' => true, + 'verify_peer_name' => true, + 'allow_self_signed' => false, + ) + ), $query); + + // Rebuild the nameserver string and set the default DoTLS port of 853 if not set before sending to TcpTransportExecutor constructor + $nameserver = 'tcp://' . $parsedNameserver['host'] . ':' . (isset($parsedNameserver['port']) ? $parsedNameserver['port'] : 853) . '/? ' . http_build_query($query); + + parent::__construct($nameserver, $loop); + + // read one chunk of data from the DNS server + // any error is fatal, this is a stream of TCP/IP data + // 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 (\PHP_VERSION_ID < 70215 || (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70303)) { + $this->readChunk = -1; + } + + // 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 (\PHP_VERSION_ID < 70018 || (\PHP_VERSION_ID >= 70100 && \PHP_VERSION_ID < 70104)) { + $this->writeChunk = 8192; // @codeCoverageIgnore + } + } + + /** + * @internal + */ + public function handleWritable() + { + if (!$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; + } + } + + parent::handleWritable(); + } +} diff --git a/src/Resolver/Factory.php b/src/Resolver/Factory.php index 5fe608cb..5d79ee24 100644 --- a/src/Resolver/Factory.php +++ b/src/Resolver/Factory.php @@ -14,6 +14,7 @@ use React\Dns\Query\RetryExecutor; use React\Dns\Query\SelectiveTransportExecutor; use React\Dns\Query\TcpTransportExecutor; +use React\Dns\Query\TlsTransportExecutor; use React\Dns\Query\TimeoutExecutor; use React\Dns\Query\UdpTransportExecutor; use React\EventLoop\Loop; @@ -169,6 +170,8 @@ private function createSingleExecutor($nameserver, LoopInterface $loop) $executor = $this->createTcpExecutor($nameserver, $loop); } elseif (isset($parts['scheme']) && $parts['scheme'] === 'udp') { $executor = $this->createUdpExecutor($nameserver, $loop); + } elseif (isset($parts['scheme']) && $parts['scheme'] === 'tls') { + $executor = $this->createTlsExecutor($nameserver, $loop); } else { $executor = new SelectiveTransportExecutor( $this->createUdpExecutor($nameserver, $loop), @@ -211,4 +214,19 @@ private function createUdpExecutor($nameserver, LoopInterface $loop) $loop ); } + + /** + * @param string $nameserver + * @param LoopInterface $loop + * @return TimeoutExecutor + * @throws \InvalidArgumentException for invalid DNS server address + */ + private function createTlsExecutor($nameserver, LoopInterface $loop) + { + return new TimeoutExecutor( + new TlsTransportExecutor($nameserver, $loop), + 5.0, + $loop + ); + } } diff --git a/tests/FunctionalResolverTest.php b/tests/FunctionalResolverTest.php index 989bf347..be1091da 100644 --- a/tests/FunctionalResolverTest.php +++ b/tests/FunctionalResolverTest.php @@ -74,6 +74,42 @@ public function testResolveGoogleOverTcpResolves() 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'); + + $promise = $this->resolver->resolve('google.com'); + $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); + + 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'); + + $promise = $this->resolver->resolve('google.com'); + $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); + + Loop::run(); + } + /** * @group internet */ diff --git a/tests/Query/TcpTransportExecutorTest.php b/tests/Query/TcpTransportExecutorTest.php index 202fdbce..828be979 100644 --- a/tests/Query/TcpTransportExecutorTest.php +++ b/tests/Query/TcpTransportExecutorTest.php @@ -400,7 +400,7 @@ public function testQueryRejectsWhenClientKeepsSendingWhenServerClosesSocketWith $this->assertNull($error); $exception = null; - $promise->then(null, function ($reason) use (&$exception) { + $promise->then($this->expectCallableNever(), function ($reason) use (&$exception) { $exception = $reason; }); @@ -410,6 +410,7 @@ public function testQueryRejectsWhenClientKeepsSendingWhenServerClosesSocketWith 'Unable to send query to DNS server tcp://' . $address . ' (', defined('SOCKET_EPIPE') && !defined('HHVM_VERSION') ? (PHP_OS !== 'Darwin' || $writePending ? SOCKET_EPIPE : SOCKET_EPROTOTYPE) : null ); + $this->assertNotNull($exception, 'Promise did not reject with an Exception'); throw $exception; } @@ -946,4 +947,17 @@ public function testQueryAgainAfterPreviousQueryResolvedWillReuseSocketAndCancel // trigger second query $executor->query($query); } + + public function testConnectionContextParamsCanBetSet() { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $options = array('socket' => array('tcp_nodelay' => true)); + $executor = new TcpTransportExecutor('tcp://127.0.0.1/?' . http_build_query($options), $loop); + + $ref = new \ReflectionProperty($executor, 'connectionParameters'); + $ref->setAccessible(true); + $data = $ref->getValue($executor); + + $this->assertEquals($options, $data); + } } diff --git a/tests/Query/TlsTransportExecutorTest.php b/tests/Query/TlsTransportExecutorTest.php new file mode 100644 index 00000000..c1a66613 --- /dev/null +++ b/tests/Query/TlsTransportExecutorTest.php @@ -0,0 +1,325 @@ +markTestSkipped('DNS over TLS not supported on legacy PHP'); + } + $this->setExpectedException('\InvalidArgumentException'); + new TlsTransportExecutor('tcp://127.0.0.1'); + } + + /** + * @group internet + */ + public function testUnsupportedLegacyPhpOverTlsRejectsWithBadMethodCall() + { + if (!(defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600)) { + $this->markTestSkipped('Tests not relevant to recent PHP versions'); + } + + $this->setExpectedException('\RuntimeException'); + + $executor = new TlsTransportExecutor('tls://8.8.8.8'); + } + + public function testQueryRejectsWhenTlsCannotBeEstablished() + { + if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) { + $this->markTestSkipped('DNS over TLS not supported on legacy PHP'); + } + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $server = \stream_socket_server('tcp://127.0.0.1:0'); + $address = \stream_socket_get_name($server, false); + $executor = new TlsTransportExecutor('tls://' . $address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $exception = null; + $executor->query($query)->then( + null, + function ($e) use (&$exception) { + $exception = $e; + } + ); + + $obj = new \ReflectionObject($executor); + $ref = $obj->getParentClass()->getProperty('writePending'); + $ref->setAccessible(true); + while($ref->getValue($executor)) { + //Call handleWritable as many times as required to perform the attempted TLS handshake + $executor->handleWritable(); + @\stream_socket_accept($server,0); + } + + /** @var \RuntimeException $exception */ + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertContains($exception->getMessage(), array( + 'DNS query for google.com (A) failed: Connection lost during TLS handshake (ECONNRESET)', + 'DNS query for google.com (A) failed: SSL: Undefined error: 0', + )); + } + + public function testQueryRejectsWhenTlsClosedDuringHandshake() + { + if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) { + $this->markTestSkipped('DNS over TLS not supported on legacy PHP'); + } + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $server = \stream_socket_server('tcp://127.0.0.1:0'); + $address = \stream_socket_get_name($server, false); + $executor = new TlsTransportExecutor('tls://' . $address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $exception = null; + $executor->query($query)->then( + null, + function ($e) use (&$exception) { + $exception = $e; + } + ); + + $obj = new \ReflectionObject($executor); + $ref = $obj->getParentClass()->getProperty('writePending'); + $ref->setAccessible(true); + while($ref->getValue($executor)) { + //Call handleWritable as many times as required to perform the attempted TLS handshake + $executor->handleWritable(); + $client = @\stream_socket_accept($server,0); + if (false !== $client) { + fclose($client); + } + } + + /** @var \RuntimeException $exception */ + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertContains($exception->getMessage(), array( + 'DNS query for google.com (A) failed: Connection lost during TLS handshake (ECONNRESET)', + 'DNS query for google.com (A) failed: SSL: Undefined error: 0', + )); + } + + public function testQueryRejectsWhenTlsCertificateVerificationFails() + { + if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) { + $this->markTestSkipped('DNS over TLS not supported on legacy PHP'); + } + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + // Connect to self-signed.badssl.com https://github.com/chromium/badssl.com + $executor = new TlsTransportExecutor('tls://104.154.89.105:443', $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $executor->query($query)->then( + null, + function ($e) use (&$exception) { + $exception = $e; + } + ); + + $obj = new \ReflectionObject($executor); + $ref = $obj->getParentClass()->getProperty('writePending'); + $ref->setAccessible(true); + while($ref->getValue($executor)) { + //Call handleWritable as many times as required to perform the TLS handshake + $executor->handleWritable(); + } + + /** @var \RuntimeException $exception */ + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertStringStartsWith('DNS query for google.com (A) failed: SSL operation failed with code ', $exception->getMessage()); + if (method_exists($this, 'assertStringContainsString')) { + $this->assertStringContainsString('certificate verify failed', $exception->getMessage()); + } else { + $this->assertContains('certificate verify failed', $exception->getMessage()); + } + } + + public function testCryptoEnabledAfterConnectingToTlsDnsServer() + { + if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) { + $this->markTestSkipped('DNS over TLS not supported on legacy PHP'); + } + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $executor = new TlsTransportExecutor('tls://8.8.8.8', $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $executor->query($query); + + $obj = new \ReflectionObject($executor); + $ref = $obj->getParentClass()->getProperty('writePending'); + $ref->setAccessible(true); + while($ref->getValue($executor)) { + //Call handleWritable as many times as required to perform the TLS handshake + $executor->handleWritable(); + } + + $ref = new \ReflectionProperty($executor, 'cryptoEnabled'); + $ref->setAccessible(true); + $this->assertTrue($ref->getValue($executor)); + } + + public function testCryptoEnabledWithPeerFingerprintMatch() + { + if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) { + $this->markTestSkipped('DNS over TLS not supported on legacy PHP'); + } + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + //1.1.1.1 used here. Google 8.8.8.8 uses two different certs at same geographical region so fingerprint match can fail + $dns = '1.1.1.1'; + $context = stream_context_create( array('ssl' => array( + 'verify_peer_name' => false, + 'capture_peer_cert' => true + ))); + $result = stream_socket_client("ssl://$dns:853", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $context); + $cont = stream_context_get_params($result); + $certificatePem = $cont['options']['ssl']['peer_certificate']; + $fingerprint = openssl_x509_fingerprint($certificatePem, 'sha1'); + + $executor = new TlsTransportExecutor('tls://1.1.1.1?ssl[peer_fingerprint]=' . $fingerprint, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $exception = null; + $executor->query($query)->then( + null, + function ($e) use (&$exception) { + $exception = $e; + } + ); + + $obj = new \ReflectionObject($executor); + $ref = $obj->getParentClass()->getProperty('writePending'); + $ref->setAccessible(true); + while($ref->getValue($executor)) { + //Call handleWritable as many times as required to perform the TLS handshake + $executor->handleWritable(); + } + $this->assertNull($exception); + + $ref = new \ReflectionProperty($executor, 'cryptoEnabled'); + $ref->setAccessible(true); + $this->assertTrue($ref->getValue($executor), 'Crypto was not enabled'); + } + + public function testCryptoFailureWithPeerFingerprintMismatch() + { + if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) { + $this->markTestSkipped('DNS over TLS not supported on legacy PHP'); + } + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $invalid_fingerprint = sha1('invalid'); + $executor = new TlsTransportExecutor('tls://8.8.8.8?ssl[peer_fingerprint]=' . $invalid_fingerprint, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $exception = null; + $executor->query($query)->then( + null, + function ($e) use (&$exception) { + $exception = $e; + } + ); + + $obj = new \ReflectionObject($executor); + $ref = $obj->getParentClass()->getProperty('writePending'); + $ref->setAccessible(true); + while($ref->getValue($executor)) { + //Call handleWritable as many times as required to perform the TLS handshake + $executor->handleWritable(); + } + + $ref = new \ReflectionProperty($executor, 'cryptoEnabled'); + $ref->setAccessible(true); + $this->assertFalse($ref->getValue($executor)); + + /** @var \RuntimeException $exception */ + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('DNS query for google.com (A) failed: peer_fingerprint match failure', $exception->getMessage()); + } + + public function testCryptoEnabledWithPeerNameVerified() + { + if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) { + $this->markTestSkipped('DNS over TLS not supported on legacy PHP'); + } + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $executor = new TlsTransportExecutor('tls://8.8.8.8?ssl[peer_name]=dns.google', $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $executor->query($query); + + $obj = new \ReflectionObject($executor); + $ref = $obj->getParentClass()->getProperty('writePending'); + $ref->setAccessible(true); + while($ref->getValue($executor)) { + //Call handleWritable as many times as required to perform the TLS handshake + $executor->handleWritable(); + } + + $ref = new \ReflectionProperty($executor, 'cryptoEnabled'); + $ref->setAccessible(true); + $this->assertTrue($ref->getValue($executor)); + } + + public function testCryptoFailureWithPeerNameVerified() + { + if (defined('HHVM_VERSION') || \PHP_VERSION_ID < 50600) { + $this->markTestSkipped('DNS over TLS not supported on legacy PHP'); + } + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $executor = new TlsTransportExecutor('tls://8.8.8.8?ssl[peer_name]=notgoogle', $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $executor->query($query)->then( + null, + function ($e) use (&$exception) { + $exception = $e; + } + ); + + $obj = new \ReflectionObject($executor); + $ref = $obj->getParentClass()->getProperty('writePending'); + $ref->setAccessible(true); + while($ref->getValue($executor)) { + //Call handleWritable as many times as required to perform the TLS handshake + $executor->handleWritable(); + } + + $ref = new \ReflectionProperty($executor, 'cryptoEnabled'); + $ref->setAccessible(true); + $this->assertFalse($ref->getValue($executor)); + + /** @var \RuntimeException $exception */ + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('DNS query for google.com (A) failed: Peer certificate CN=`dns.google\' did not match expected CN=`notgoogle\'', $exception->getMessage()); + } +}