diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcf703ea..646c2deb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,10 +7,11 @@ on: jobs: PHPUnit: name: PHPUnit (PHP ${{ matrix.php }}) - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: php: + - 8.2 - 8.1 - 8.0 - 7.4 @@ -23,11 +24,12 @@ jobs: - 5.4 - 5.3 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: xdebug + ini-file: development - run: composer install - run: vendor/bin/phpunit --coverage-text if: ${{ matrix.php >= 7.3 }} @@ -36,26 +38,30 @@ jobs: PHPUnit-macOS: name: PHPUnit (macOS) - runs-on: macos-10.15 + runs-on: macos-12 continue-on-error: true steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: shivammathur/setup-php@v2 with: - php-version: 8.0 + php-version: 8.1 coverage: xdebug + ini-file: development - run: composer install - run: vendor/bin/phpunit --coverage-text PHPUnit-hhvm: name: PHPUnit (HHVM) - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 continue-on-error: true steps: - - uses: actions/checkout@v2 - - uses: azjezz/setup-hhvm@v1 + - uses: actions/checkout@v3 + - run: cp "$(which composer)" composer.phar && ./composer.phar self-update --2.2 # downgrade Composer for HHVM + - name: Run hhvm composer.phar install + uses: docker://hhvm/hhvm:3.30-lts-latest with: - version: lts-3.30 - - run: composer self-update --2.2 # downgrade Composer for HHVM - - run: hhvm $(which composer) install - - run: hhvm vendor/bin/phpunit + args: hhvm composer.phar install + - name: Run hhvm vendor/bin/phpunit + uses: docker://hhvm/hhvm:3.30-lts-latest + with: + args: hhvm vendor/bin/phpunit diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aaa004d..ca60df32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 1.11.0 (2023-06-02) + +* Feature: Include timeout logic to avoid dependency on reactphp/promise-timer. + (#213 by @clue) + +* Improve test suite and project setup and report failed assertions. + (#210 by @clue, #212 by @WyriHaximus and #209 and #211 by @SimonFrings) + +## 1.10.0 (2022-09-08) + +* Feature: Full support for PHP 8.2 release. + (#201 by @clue and #207 by @WyriHaximus) + +* Feature: Optimize forward compatibility with Promise v3, avoid hitting autoloader. + (#202 by @clue) + +* Feature / Fix: Improve error reporting when custom error handler is used. + (#197 by @clue) + +* Fix: Fix invalid references in exception stack trace. + (#191 by @clue) + +* Minor documentation improvements. + (#195 by @SimonFrings and #203 by @nhedger) + +* Improve test suite, update to use default loop and new reactphp/async package. + (#204, #205 and #206 by @clue and #196 by @SimonFrings) + ## 1.9.0 (2021-12-20) * Feature: Full support for PHP 8.1 release and prepare PHP 8.2 compatibility diff --git a/README.md b/README.md index 6bc53f97..8112eabe 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) + * [DNS over TLS](#dns-over-tls-dot) * [SelectiveTransportExecutor](#selectivetransportexecutor) * [HostsFileExecutor](#hostsfileexecutor) * [Install](#install) @@ -115,7 +116,7 @@ See also the wiki for possible [cache implementations](https://github.com/reactp ### resolve() -The `resolve(string $domain): PromiseInterface` method can be used to +The `resolve(string $domain): PromiseInterface` method can be used to resolve the given $domain name to a single IPv4 address (type `A` query). ```php @@ -151,7 +152,7 @@ $promise->cancel(); ### resolveAll() -The `resolveAll(string $host, int $type): PromiseInterface` method can be used to +The `resolveAll(string $host, int $type): PromiseInterface` method can be used to resolve all record values for the given $domain name and query $type. ```php @@ -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 @@ -410,7 +432,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version: ```bash -$ composer require react/dns:^1.9 +composer require react/dns:^1.11 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. @@ -426,13 +448,13 @@ To run the test suite, you first need to clone this repo and then install all dependencies [through Composer](https://getcomposer.org/): ```bash -$ composer install +composer install ``` To run the test suite, go to the project root and run: ```bash -$ vendor/bin/phpunit +vendor/bin/phpunit ``` The test suite also contains a number of functional integration tests that rely @@ -440,7 +462,7 @@ on a stable internet connection. If you do not want to run these, they can simply be skipped like this: ```bash -$ vendor/bin/phpunit --exclude-group internet +vendor/bin/phpunit --exclude-group internet ``` ## License diff --git a/composer.json b/composer.json index 0126343f..181f2d88 100644 --- a/composer.json +++ b/composer.json @@ -29,17 +29,21 @@ "php": ">=5.3.0", "react/cache": "^1.0 || ^0.6 || ^0.5", "react/event-loop": "^1.2", - "react/promise": "^3.0 || ^2.7 || ^1.2.1", - "react/promise-timer": "^1.8" + "react/promise": "^3.0 || ^2.7 || ^1.2.1" }, "require-dev": { - "clue/block-react": "^1.2", - "phpunit/phpunit": "^9.3 || ^4.8.35" + "phpunit/phpunit": "^9.5 || ^4.8.35", + "react/async": "^4 || ^3 || ^2", + "react/promise-timer": "^1.9" }, "autoload": { - "psr-4": { "React\\Dns\\": "src" } + "psr-4": { + "React\\Dns\\": "src/" + } }, "autoload-dev": { - "psr-4": { "React\\Tests\\Dns\\": "tests" } + "psr-4": { + "React\\Tests\\Dns\\": "tests/" + } } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 93a36f6b..7a9577e9 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,8 +1,8 @@ - - +./src/ + + + + + + + + diff --git a/phpunit.xml.legacy b/phpunit.xml.legacy index fbb43e85..ac5600ae 100644 --- a/phpunit.xml.legacy +++ b/phpunit.xml.legacy @@ -1,6 +1,6 @@ - + ./src/ + + + + + + + + diff --git a/src/Query/ExecutorInterface.php b/src/Query/ExecutorInterface.php index b356dc62..0bc3945f 100644 --- a/src/Query/ExecutorInterface.php +++ b/src/Query/ExecutorInterface.php @@ -36,7 +36,7 @@ interface ExecutorInterface * ``` * * @param Query $query - * @return \React\Promise\PromiseInterface<\React\Dns\Model\Message,\Exception> + * @return \React\Promise\PromiseInterface<\React\Dns\Model\Message> * resolves with response message on success or rejects with an Exception on error */ public function query(Query $query); diff --git a/src/Query/RetryExecutor.php b/src/Query/RetryExecutor.php index 64a15642..880609b2 100644 --- a/src/Query/RetryExecutor.php +++ b/src/Query/RetryExecutor.php @@ -2,7 +2,6 @@ namespace React\Dns\Query; -use React\Promise\CancellablePromiseInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; @@ -25,7 +24,7 @@ public function query(Query $query) public function tryQuery(Query $query, $retries) { $deferred = new Deferred(function () use (&$promise) { - if ($promise instanceof CancellablePromiseInterface || (!\interface_exists('React\Promise\CancellablePromiseInterface') && \method_exists($promise, 'cancel'))) { + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } }); diff --git a/src/Query/TcpTransportExecutor.php b/src/Query/TcpTransportExecutor.php index bfaedbae..0ac912f0 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,30 @@ public function handleWritable() $errstr = isset($m[2]) ? $m[2] : $error; }); - $written = \fwrite($this->socket, $this->writeBuffer); + $written = \fwrite($this->socket, $this->writeBuffer, $this->writeChunk); + + // 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 ($written === false || $written === 0) { - $this->closeError( - 'Unable to send query to DNS server ' . $this->nameserver . ' (' . $errstr . ')', - $errno - ); - return; - } - if (isset($this->writeBuffer[$written])) { $this->writeBuffer = \substr($this->writeBuffer, $written); } else { @@ -282,9 +308,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 +383,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/TimeoutExecutor.php b/src/Query/TimeoutExecutor.php index 15c8c22a..06c51b15 100644 --- a/src/Query/TimeoutExecutor.php +++ b/src/Query/TimeoutExecutor.php @@ -4,7 +4,7 @@ use React\EventLoop\Loop; use React\EventLoop\LoopInterface; -use React\Promise\Timer; +use React\Promise\Promise; final class TimeoutExecutor implements ExecutorInterface { @@ -21,11 +21,49 @@ public function __construct(ExecutorInterface $executor, $timeout, LoopInterface public function query(Query $query) { - return Timer\timeout($this->executor->query($query), $this->timeout, $this->loop)->then(null, function ($e) use ($query) { - if ($e instanceof Timer\TimeoutException) { - $e = new TimeoutException(sprintf("DNS query for %s timed out", $query->describe()), 0, $e); + $promise = $this->executor->query($query); + + $loop = $this->loop; + $time = $this->timeout; + return new Promise(function ($resolve, $reject) use ($loop, $time, $promise, $query) { + $timer = null; + $promise = $promise->then(function ($v) use (&$timer, $loop, $resolve) { + if ($timer) { + $loop->cancelTimer($timer); + } + $timer = false; + $resolve($v); + }, function ($v) use (&$timer, $loop, $reject) { + if ($timer) { + $loop->cancelTimer($timer); + } + $timer = false; + $reject($v); + }); + + // promise already resolved => no need to start timer + if ($timer === false) { + return; } - throw $e; + + // start timeout timer which will cancel the pending promise + $timer = $loop->addTimer($time, function () use ($time, &$promise, $reject, $query) { + $reject(new TimeoutException( + 'DNS query for ' . $query->describe() . ' timed out' + )); + + // Cancel pending query to clean up any underlying resources and references. + // Avoid garbage references in call stack by passing pending promise by reference. + assert(\method_exists($promise, 'cancel')); + $promise->cancel(); + $promise = null; + }); + }, function () use (&$promise) { + // Cancelling this promise will cancel the pending query, thus triggering the rejection logic above. + // Avoid garbage references in call stack by passing pending promise by reference. + assert(\method_exists($promise, 'cancel')); + $promise->cancel(); + $promise = null; }); } } diff --git a/src/Query/TlsTransportExecutor.php b/src/Query/TlsTransportExecutor.php new file mode 100644 index 00000000..2bd47337 --- /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 \BadMethodCallException('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/src/Resolver/ResolverInterface.php b/src/Resolver/ResolverInterface.php index fe937dc7..555a1cb1 100644 --- a/src/Resolver/ResolverInterface.php +++ b/src/Resolver/ResolverInterface.php @@ -39,7 +39,7 @@ interface ResolverInterface * ``` * * @param string $domain - * @return \React\Promise\PromiseInterface + * @return \React\Promise\PromiseInterface * resolves with a single IP address on success or rejects with an Exception on error. */ public function resolve($domain); @@ -87,7 +87,7 @@ public function resolve($domain); * ``` * * @param string $domain - * @return \React\Promise\PromiseInterface + * @return \React\Promise\PromiseInterface * Resolves with all record values on success or rejects with an Exception on error. */ public function resolveAll($domain, $type); diff --git a/tests/FunctionalResolverTest.php b/tests/FunctionalResolverTest.php index 9cb05615..0d906169 100644 --- a/tests/FunctionalResolverTest.php +++ b/tests/FunctionalResolverTest.php @@ -2,22 +2,21 @@ namespace React\Tests\Dns; -use React\EventLoop\Factory as LoopFactory; use React\Dns\Resolver\Factory; -use React\Dns\RecordNotFoundException; use React\Dns\Model\Message; +use React\EventLoop\Loop; class FunctionalResolverTest extends TestCase { + private $resolver; + /** * @before */ public function setUpResolver() { - $this->loop = LoopFactory::create(); - $factory = new Factory(); - $this->resolver = $factory->create('8.8.8.8', $this->loop); + $this->resolver = $factory->create('8.8.8.8'); } public function testResolveLocalhostResolves() @@ -25,7 +24,7 @@ public function testResolveLocalhostResolves() $promise = $this->resolver->resolve('localhost'); $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); - $this->loop->run(); + Loop::run(); } public function testResolveAllLocalhostResolvesWithArray() @@ -33,7 +32,7 @@ public function testResolveAllLocalhostResolvesWithArray() $promise = $this->resolver->resolveAll('localhost', Message::TYPE_A); $promise->then($this->expectCallableOnceWith($this->isType('array')), $this->expectCallableNever()); - $this->loop->run(); + Loop::run(); } /** @@ -44,7 +43,7 @@ public function testResolveGoogleResolves() $promise = $this->resolver->resolve('google.com'); $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); - $this->loop->run(); + Loop::run(); } /** @@ -52,13 +51,13 @@ public function testResolveGoogleResolves() */ public function testResolveGoogleOverUdpResolves() { - $factory = new Factory($this->loop); - $this->resolver = $factory->create('udp://8.8.8.8', $this->loop); + $factory = new Factory(); + $this->resolver = $factory->create('udp://8.8.8.8'); $promise = $this->resolver->resolve('google.com'); $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); - $this->loop->run(); + Loop::run(); } /** @@ -66,13 +65,73 @@ public function testResolveGoogleOverUdpResolves() */ public function testResolveGoogleOverTcpResolves() { - $factory = new Factory($this->loop); - $this->resolver = $factory->create('tcp://8.8.8.8', $this->loop); + $factory = new Factory(); + $this->resolver = $factory->create('tcp://8.8.8.8'); $promise = $this->resolver->resolve('google.com'); $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); - $this->loop->run(); + 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 + */ + 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); + + Loop::run(); } /** @@ -81,12 +140,12 @@ public function testResolveGoogleOverTcpResolves() public function testResolveAllGoogleMxResolvesWithCache() { $factory = new Factory(); - $this->resolver = $factory->createCached('8.8.8.8', $this->loop); + $this->resolver = $factory->createCached('8.8.8.8'); $promise = $this->resolver->resolveAll('google.com', Message::TYPE_MX); $promise->then($this->expectCallableOnceWith($this->isType('array')), $this->expectCallableNever()); - $this->loop->run(); + Loop::run(); } /** * @group internet @@ -94,12 +153,12 @@ public function testResolveAllGoogleMxResolvesWithCache() public function testResolveAllGoogleCaaResolvesWithCache() { $factory = new Factory(); - $this->resolver = $factory->createCached('8.8.8.8', $this->loop); + $this->resolver = $factory->createCached('8.8.8.8'); $promise = $this->resolver->resolveAll('google.com', Message::TYPE_CAA); $promise->then($this->expectCallableOnceWith($this->isType('array')), $this->expectCallableNever()); - $this->loop->run(); + Loop::run(); } /** @@ -109,7 +168,7 @@ public function testResolveInvalidRejects() { $promise = $this->resolver->resolve('example.invalid'); - $this->loop->run(); + Loop::run(); $exception = null; $promise->then(null, function ($reason) use (&$exception) { @@ -131,7 +190,7 @@ public function testResolveCancelledRejectsImmediately() $promise->cancel(); $time = microtime(true); - $this->loop->run(); + Loop::run(); $time = microtime(true) - $time; $this->assertLessThan(0.1, $time); @@ -153,7 +212,7 @@ public function testResolveAllInvalidTypeRejects() { $promise = $this->resolver->resolveAll('google.com', Message::TYPE_PTR); - $this->loop->run(); + Loop::run(); $exception = null; $promise->then(null, function ($reason) use (&$exception) { @@ -169,7 +228,7 @@ public function testResolveAllInvalidTypeRejects() public function testInvalidResolverDoesNotResolveGoogle() { $factory = new Factory(); - $this->resolver = $factory->create('255.255.255.255', $this->loop); + $this->resolver = $factory->create('255.255.255.255'); $promise = $this->resolver->resolve('google.com'); $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); @@ -182,7 +241,7 @@ public function testResolveShouldNotCauseGarbageReferencesWhenUsingInvalidNamese } $factory = new Factory(); - $this->resolver = $factory->create('255.255.255.255', $this->loop); + $this->resolver = $factory->create('255.255.255.255'); gc_collect_cycles(); gc_collect_cycles(); // clear twice to avoid leftovers in PHP 7.4 with ext-xdebug and code coverage turned on @@ -200,7 +259,7 @@ public function testResolveCachedShouldNotCauseGarbageReferencesWhenUsingInvalid } $factory = new Factory(); - $this->resolver = $factory->createCached('255.255.255.255', $this->loop); + $this->resolver = $factory->createCached('255.255.255.255'); gc_collect_cycles(); gc_collect_cycles(); // clear twice to avoid leftovers in PHP 7.4 with ext-xdebug and code coverage turned on @@ -218,7 +277,7 @@ public function testCancelResolveShouldNotCauseGarbageReferences() } $factory = new Factory(); - $this->resolver = $factory->create('127.0.0.1', $this->loop); + $this->resolver = $factory->create('127.0.0.1'); gc_collect_cycles(); gc_collect_cycles(); // clear twice to avoid leftovers in PHP 7.4 with ext-xdebug and code coverage turned on @@ -237,7 +296,7 @@ public function testCancelResolveCachedShouldNotCauseGarbageReferences() } $factory = new Factory(); - $this->resolver = $factory->createCached('127.0.0.1', $this->loop); + $this->resolver = $factory->createCached('127.0.0.1'); gc_collect_cycles(); gc_collect_cycles(); // clear twice to avoid leftovers in PHP 7.4 with ext-xdebug and code coverage turned on diff --git a/tests/Protocol/ParserTest.php b/tests/Protocol/ParserTest.php index 0624526a..7cf9cbe7 100644 --- a/tests/Protocol/ParserTest.php +++ b/tests/Protocol/ParserTest.php @@ -8,6 +8,8 @@ class ParserTest extends TestCase { + private $parser; + /** * @before */ diff --git a/tests/Query/SelectiveTransportExecutorTest.php b/tests/Query/SelectiveTransportExecutorTest.php index a6cf29e0..9e0e0c84 100644 --- a/tests/Query/SelectiveTransportExecutorTest.php +++ b/tests/Query/SelectiveTransportExecutorTest.php @@ -11,6 +11,10 @@ class SelectiveTransportExecutorTest extends TestCase { + private $datagram; + private $stream; + private $executor; + /** * @before */ diff --git a/tests/Query/TcpTransportExecutorTest.php b/tests/Query/TcpTransportExecutorTest.php index 860ad0dc..828be979 100644 --- a/tests/Query/TcpTransportExecutorTest.php +++ b/tests/Query/TcpTransportExecutorTest.php @@ -7,7 +7,7 @@ use React\Dns\Protocol\Parser; use React\Dns\Query\Query; use React\Dns\Query\TcpTransportExecutor; -use React\EventLoop\Factory; +use React\EventLoop\Loop; use React\Tests\Dns\TestCase; class TcpTransportExecutorTest extends TestCase @@ -215,7 +215,6 @@ public function testQueryRejectsOnCancellationWithoutClosingSocketAndWithoutStar $loop->expects($this->never())->method('addReadStream'); $loop->expects($this->never())->method('removeReadStream'); - $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); $loop->expects($this->never())->method('addTimer'); $loop->expects($this->never())->method('cancelTimer'); @@ -255,9 +254,7 @@ public function testQueryAgainAfterPreviousWasCancelledReusesExistingSocket() public function testQueryRejectsWhenServerIsNotListening() { - $loop = Factory::create(); - - $executor = new TcpTransportExecutor('127.0.0.1:1', $loop); + $executor = new TcpTransportExecutor('127.0.0.1:1'); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); @@ -269,9 +266,9 @@ function ($e) use (&$exception) { } ); - \Clue\React\Block\sleep(0.01, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.01)); if ($exception === null) { - \Clue\React\Block\sleep(0.2, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.2)); } /** @var \RuntimeException $exception */ @@ -403,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; }); @@ -413,21 +410,23 @@ 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; } public function testQueryRejectsWhenServerClosesConnection() { - $loop = Factory::create(); - $server = stream_socket_server('tcp://127.0.0.1:0'); - $loop->addReadStream($server, function ($server) use ($loop) { + Loop::addReadStream($server, function ($server) { $client = stream_socket_accept($server); fclose($client); + + Loop::removeReadStream($server); + fclose($server); }); $address = stream_socket_get_name($server, false); - $executor = new TcpTransportExecutor($address, $loop); + $executor = new TcpTransportExecutor($address); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); @@ -439,9 +438,9 @@ function ($e) use (&$exception) { } ); - \Clue\React\Block\sleep(0.01, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.01)); if ($exception === null) { - \Clue\React\Block\sleep(0.2, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.2)); } /** @var \RuntimeException $exception */ @@ -451,22 +450,21 @@ function ($e) use (&$exception) { public function testQueryKeepsPendingIfServerSendsIncompleteMessageLength() { - $loop = Factory::create(); - + $client = null; $server = stream_socket_server('tcp://127.0.0.1:0'); - $loop->addReadStream($server, function ($server) use ($loop) { + Loop::addReadStream($server, function ($server) use (&$client) { $client = stream_socket_accept($server); - $loop->addReadStream($client, function ($client) use ($loop) { - $loop->removeReadStream($client); + Loop::addReadStream($client, function ($client) { + Loop::removeReadStream($client); fwrite($client, "\x00"); }); - // keep reference to client to avoid disconnecting - $loop->addTimer(1, function () use ($client) { }); + Loop::removeReadStream($server); + fclose($server); }); $address = stream_socket_get_name($server, false); - $executor = new TcpTransportExecutor($address, $loop); + $executor = new TcpTransportExecutor($address); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); @@ -479,28 +477,31 @@ function ($e) use (&$wait) { } ); - \Clue\React\Block\sleep(0.2, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.2)); $this->assertTrue($wait); + + $this->assertNotNull($client); + fclose($client); + Loop::removeReadStream($client); } public function testQueryKeepsPendingIfServerSendsIncompleteMessageBody() { - $loop = Factory::create(); - + $client = null; $server = stream_socket_server('tcp://127.0.0.1:0'); - $loop->addReadStream($server, function ($server) use ($loop) { + Loop::addReadStream($server, function ($server) use (&$client) { $client = stream_socket_accept($server); - $loop->addReadStream($client, function ($client) use ($loop) { - $loop->removeReadStream($client); + Loop::addReadStream($client, function ($client) { + Loop::removeReadStream($client); fwrite($client, "\x00\xff" . "some incomplete message data"); }); - // keep reference to client to avoid disconnecting - $loop->addTimer(1, function () use ($client) { }); + Loop::removeReadStream($server); + fclose($server); }); $address = stream_socket_get_name($server, false); - $executor = new TcpTransportExecutor($address, $loop); + $executor = new TcpTransportExecutor($address); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); @@ -513,25 +514,30 @@ function ($e) use (&$wait) { } ); - \Clue\React\Block\sleep(0.2, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.2)); $this->assertTrue($wait); + + $this->assertNotNull($client); + fclose($client); + Loop::removeReadStream($client); } public function testQueryRejectsWhenServerSendsInvalidMessage() { - $loop = Factory::create(); - $server = stream_socket_server('tcp://127.0.0.1:0'); - $loop->addReadStream($server, function ($server) use ($loop) { + Loop::addReadStream($server, function ($server) { $client = stream_socket_accept($server); - $loop->addReadStream($client, function ($client) use ($loop) { - $loop->removeReadStream($client); + Loop::addReadStream($client, function ($client) { + Loop::removeReadStream($client); fwrite($client, "\x00\x0f" . 'invalid message'); }); + + Loop::removeReadStream($server); + fclose($server); }); $address = stream_socket_get_name($server, false); - $executor = new TcpTransportExecutor($address, $loop); + $executor = new TcpTransportExecutor($address); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); @@ -543,9 +549,9 @@ function ($e) use (&$exception) { } ); - \Clue\React\Block\sleep(0.01, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.01)); if ($exception === null) { - \Clue\React\Block\sleep(0.2, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.2)); } /** @var \RuntimeException $exception */ @@ -558,13 +564,11 @@ public function testQueryRejectsWhenServerSendsInvalidId() $parser = new Parser(); $dumper = new BinaryDumper(); - $loop = Factory::create(); - $server = stream_socket_server('tcp://127.0.0.1:0'); - $loop->addReadStream($server, function ($server) use ($loop, $parser, $dumper) { + Loop::addReadStream($server, function ($server) use ($parser, $dumper) { $client = stream_socket_accept($server); - $loop->addReadStream($client, function ($client) use ($loop, $parser, $dumper) { - $loop->removeReadStream($client); + Loop::addReadStream($client, function ($client) use ($parser, $dumper) { + Loop::removeReadStream($client); $data = fread($client, 512); list(, $length) = unpack('n', substr($data, 0, 2)); @@ -579,10 +583,13 @@ public function testQueryRejectsWhenServerSendsInvalidId() fwrite($client, $data); }); + + Loop::removeReadStream($server); + fclose($server); }); $address = stream_socket_get_name($server, false); - $executor = new TcpTransportExecutor($address, $loop); + $executor = new TcpTransportExecutor($address); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); @@ -594,9 +601,9 @@ function ($e) use (&$exception) { } ); - \Clue\React\Block\sleep(0.01, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.01)); if ($exception === null) { - \Clue\React\Block\sleep(0.2, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.2)); } /** @var \RuntimeException $exception */ @@ -609,13 +616,11 @@ public function testQueryRejectsIfServerSendsTruncatedResponse() $parser = new Parser(); $dumper = new BinaryDumper(); - $loop = Factory::create(); - $server = stream_socket_server('tcp://127.0.0.1:0'); - $loop->addReadStream($server, function ($server) use ($loop, $parser, $dumper) { + Loop::addReadStream($server, function ($server) use ($parser, $dumper) { $client = stream_socket_accept($server); - $loop->addReadStream($client, function ($client) use ($loop, $parser, $dumper) { - $loop->removeReadStream($client); + Loop::addReadStream($client, function ($client) use ($parser, $dumper) { + Loop::removeReadStream($client); $data = fread($client, 512); list(, $length) = unpack('n', substr($data, 0, 2)); @@ -630,10 +635,13 @@ public function testQueryRejectsIfServerSendsTruncatedResponse() fwrite($client, $data); }); + + Loop::removeReadStream($server); + fclose($server); }); $address = stream_socket_get_name($server, false); - $executor = new TcpTransportExecutor($address, $loop); + $executor = new TcpTransportExecutor($address); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); @@ -645,9 +653,9 @@ function ($e) use (&$exception) { } ); - \Clue\React\Block\sleep(0.01, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.01)); if ($exception === null) { - \Clue\React\Block\sleep(0.2, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.2)); } /** @var \RuntimeException $exception */ @@ -657,13 +665,11 @@ function ($e) use (&$exception) { public function testQueryResolvesIfServerSendsValidResponse() { - $loop = Factory::create(); - $server = stream_socket_server('tcp://127.0.0.1:0'); - $loop->addReadStream($server, function ($server) use ($loop) { + Loop::addReadStream($server, function ($server) { $client = stream_socket_accept($server); - $loop->addReadStream($client, function ($client) use ($loop) { - $loop->removeReadStream($client); + Loop::addReadStream($client, function ($client) { + Loop::removeReadStream($client); $data = fread($client, 512); list(, $length) = unpack('n', substr($data, 0, 2)); @@ -671,15 +677,18 @@ public function testQueryResolvesIfServerSendsValidResponse() fwrite($client, $data); }); + + Loop::removeReadStream($server); + fclose($server); }); $address = stream_socket_get_name($server, false); - $executor = new TcpTransportExecutor($address, $loop); + $executor = new TcpTransportExecutor($address); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); $promise = $executor->query($query); - $response = \Clue\React\Block\await($promise, $loop, 0.2); + $response = \React\Async\await(\React\Promise\Timer\timeout($promise, 0.2)); $this->assertInstanceOf('React\Dns\Model\Message', $response); } @@ -938,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/TimeoutExecutorTest.php b/tests/Query/TimeoutExecutorTest.php index a7537053..b7857783 100644 --- a/tests/Query/TimeoutExecutorTest.php +++ b/tests/Query/TimeoutExecutorTest.php @@ -7,26 +7,25 @@ use React\Dns\Query\Query; use React\Dns\Query\TimeoutException; use React\Dns\Query\TimeoutExecutor; -use React\EventLoop\Factory; use React\Promise; use React\Promise\Deferred; use React\Tests\Dns\TestCase; class TimeoutExecutorTest extends TestCase { - private $loop; private $wrapped; private $executor; + private $loop; /** * @before */ public function setUpExecutor() { - $this->loop = Factory::create(); - $this->wrapped = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $this->loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $this->executor = new TimeoutExecutor($this->wrapped, 5.0, $this->loop); } @@ -43,6 +42,10 @@ public function testCtorWithoutLoopShouldAssignDefaultLoop() public function testCancellingPromiseWillCancelWrapped() { + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with(5.0, $this->anything())->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + $cancelled = 0; $this->wrapped @@ -67,8 +70,11 @@ public function testCancellingPromiseWillCancelWrapped() $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); } - public function testResolvesPromiseWhenWrappedResolves() + public function testResolvesPromiseWithoutStartingTimerWhenWrappedReturnsResolvedPromise() { + $this->loop->expects($this->never())->method('addTimer'); + $this->loop->expects($this->never())->method('cancelTimer'); + $this->wrapped ->expects($this->once()) ->method('query') @@ -80,8 +86,31 @@ public function testResolvesPromiseWhenWrappedResolves() $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); } - public function testRejectsPromiseWhenWrappedRejects() + public function testResolvesPromiseAfterCancellingTimerWhenWrappedReturnsPendingPromiseThatResolves() { + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with(5.0, $this->anything())->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $deferred = new Deferred(); + $this->wrapped + ->expects($this->once()) + ->method('query') + ->willReturn($deferred->promise()); + + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $promise = $this->executor->query($query); + + $deferred->resolve('0.0.0.0'); + + $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); + } + + public function testRejectsPromiseWithoutStartingTimerWhenWrappedReturnsRejectedPromise() + { + $this->loop->expects($this->never())->method('addTimer'); + $this->loop->expects($this->never())->method('cancelTimer'); + $this->wrapped ->expects($this->once()) ->method('query') @@ -93,9 +122,35 @@ public function testRejectsPromiseWhenWrappedRejects() $promise->then($this->expectCallableNever(), $this->expectCallableOnceWith(new \RuntimeException())); } - public function testWrappedWillBeCancelledOnTimeout() + public function testRejectsPromiseAfterCancellingTimerWhenWrappedReturnsPendingPromiseThatRejects() { - $this->executor = new TimeoutExecutor($this->wrapped, 0, $this->loop); + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with(5.0, $this->anything())->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $deferred = new Deferred(); + $this->wrapped + ->expects($this->once()) + ->method('query') + ->willReturn($deferred->promise()); + + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $promise = $this->executor->query($query); + + $deferred->reject(new \RuntimeException()); + + $promise->then($this->expectCallableNever(), $this->expectCallableOnceWith(new \RuntimeException())); + } + + public function testRejectsPromiseAndCancelsPendingQueryWhenTimeoutTriggers() + { + $timerCallback = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with(5.0, $this->callback(function ($callback) use (&$timerCallback) { + $timerCallback = $callback; + return true; + }))->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); $cancelled = 0; @@ -116,13 +171,18 @@ public function testWrappedWillBeCancelledOnTimeout() $this->assertEquals(0, $cancelled); - try { - \Clue\React\Block\await($promise, $this->loop); - $this->fail(); - } catch (TimeoutException $exception) { - $this->assertEquals('DNS query for igor.io (A) timed out' , $exception->getMessage()); - } + $this->assertNotNull($timerCallback); + $timerCallback(); $this->assertEquals(1, $cancelled); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof TimeoutException); + $this->assertInstanceOf('React\Dns\Query\TimeoutException', $exception); + $this->assertEquals('DNS query for igor.io (A) timed out' , $exception->getMessage()); } } diff --git a/tests/Query/TlsTransportExecutorTest.php b/tests/Query/TlsTransportExecutorTest.php new file mode 100644 index 00000000..3c9b7a82 --- /dev/null +++ b/tests/Query/TlsTransportExecutorTest.php @@ -0,0 +1,306 @@ +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()); + } +} diff --git a/tests/Query/UdpTransportExecutorTest.php b/tests/Query/UdpTransportExecutorTest.php index be674f71..81cf9497 100644 --- a/tests/Query/UdpTransportExecutorTest.php +++ b/tests/Query/UdpTransportExecutorTest.php @@ -7,7 +7,7 @@ use React\Dns\Protocol\Parser; use React\Dns\Query\Query; use React\Dns\Query\UdpTransportExecutor; -use React\EventLoop\Factory; +use React\EventLoop\Loop; use React\Tests\Dns\TestCase; class UdpTransportExecutorTest extends TestCase @@ -250,16 +250,17 @@ public function testQueryRejectsOnCancellation() public function testQueryKeepsPendingIfServerSendsInvalidMessage() { - $loop = Factory::create(); - $server = stream_socket_server('udp://127.0.0.1:0', $errno, $errstr, STREAM_SERVER_BIND); - $loop->addReadStream($server, function ($server) { + Loop::addReadStream($server, function ($server) { $data = stream_socket_recvfrom($server, 512, 0, $peer); stream_socket_sendto($server, 'invalid', 0, $peer); + + Loop::removeReadStream($server); + fclose($server); }); $address = stream_socket_get_name($server, false); - $executor = new UdpTransportExecutor($address, $loop); + $executor = new UdpTransportExecutor($address); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); @@ -272,8 +273,10 @@ function ($e) use (&$wait) { } ); - \Clue\React\Block\sleep(0.2, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.2)); $this->assertTrue($wait); + + $promise->cancel(); } public function testQueryKeepsPendingIfServerSendsInvalidId() @@ -281,20 +284,21 @@ public function testQueryKeepsPendingIfServerSendsInvalidId() $parser = new Parser(); $dumper = new BinaryDumper(); - $loop = Factory::create(); - $server = stream_socket_server('udp://127.0.0.1:0', $errno, $errstr, STREAM_SERVER_BIND); - $loop->addReadStream($server, function ($server) use ($parser, $dumper) { + Loop::addReadStream($server, function ($server) use ($parser, $dumper) { $data = stream_socket_recvfrom($server, 512, 0, $peer); $message = $parser->parseMessage($data); $message->id = 0; stream_socket_sendto($server, $dumper->toBinary($message), 0, $peer); + + Loop::removeReadStream($server); + fclose($server); }); $address = stream_socket_get_name($server, false); - $executor = new UdpTransportExecutor($address, $loop); + $executor = new UdpTransportExecutor($address); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); @@ -307,8 +311,10 @@ function ($e) use (&$wait) { } ); - \Clue\React\Block\sleep(0.2, $loop); + \React\Async\await(\React\Promise\Timer\sleep(0.2)); $this->assertTrue($wait); + + $promise->cancel(); } public function testQueryRejectsIfServerSendsTruncatedResponse() @@ -316,20 +322,21 @@ public function testQueryRejectsIfServerSendsTruncatedResponse() $parser = new Parser(); $dumper = new BinaryDumper(); - $loop = Factory::create(); - $server = stream_socket_server('udp://127.0.0.1:0', $errno, $errstr, STREAM_SERVER_BIND); - $loop->addReadStream($server, function ($server) use ($parser, $dumper) { + Loop::addReadStream($server, function ($server) use ($parser, $dumper) { $data = stream_socket_recvfrom($server, 512, 0, $peer); $message = $parser->parseMessage($data); $message->tc = true; stream_socket_sendto($server, $dumper->toBinary($message), 0, $peer); + + Loop::removeReadStream($server); + fclose($server); }); $address = stream_socket_get_name($server, false); - $executor = new UdpTransportExecutor($address, $loop); + $executor = new UdpTransportExecutor($address); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); @@ -340,7 +347,7 @@ public function testQueryRejectsIfServerSendsTruncatedResponse() 'DNS query for google.com (A) failed: The DNS server udp://' . $address . ' returned a truncated result for a UDP query', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90 ); - \Clue\React\Block\await($promise, $loop, 0.1); + \React\Async\await(\React\Promise\Timer\timeout($promise, 0.1)); } public function testQueryResolvesIfServerSendsValidResponse() @@ -348,24 +355,25 @@ public function testQueryResolvesIfServerSendsValidResponse() $parser = new Parser(); $dumper = new BinaryDumper(); - $loop = Factory::create(); - $server = stream_socket_server('udp://127.0.0.1:0', $errno, $errstr, STREAM_SERVER_BIND); - $loop->addReadStream($server, function ($server) use ($parser, $dumper) { + Loop::addReadStream($server, function ($server) use ($parser, $dumper) { $data = stream_socket_recvfrom($server, 512, 0, $peer); $message = $parser->parseMessage($data); stream_socket_sendto($server, $dumper->toBinary($message), 0, $peer); + + Loop::removeReadStream($server); + fclose($server); }); $address = stream_socket_get_name($server, false); - $executor = new UdpTransportExecutor($address, $loop); + $executor = new UdpTransportExecutor($address); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); $promise = $executor->query($query); - $response = \Clue\React\Block\await($promise, $loop, 0.2); + $response = \React\Async\await(\React\Promise\Timer\timeout($promise, 0.2)); $this->assertInstanceOf('React\Dns\Model\Message', $response); }