diff --git a/CHANGELOG.md b/CHANGELOG.md index adad0a7..d16f2f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,39 @@ # Changelog + +## 0.4.15 (2018-07-02) + +* Feature: Add `resolveAll()` method to support custom query types in `Resolver`. + (#110 by @clue and @WyriHaximus) + + ```php + $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then(function ($ips) { + echo 'IPv6 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; + }); + ``` + +* Feature: Support parsing `NS`, `TXT`, `MX`, `SOA` and `SRV` records. + (#104, #105, #106, #107 and #108 by @clue) + +* Feature: Add support for `Message::TYPE_ANY` parse unknown types as binary data. + (#104 by @clue) + +* Feature: Improve error messages for failed queries and improve documentation. + (#109 by @clue) + +* Feature: Add reverse DNS lookup example. + (#111 by @clue) + +## 0.4.14 (2018-06-26) + +* Feature: Add `UdpTransportExecutor`, validate incoming DNS response messages + to avoid cache poisoning attacks and deprecate legacy `Executor`. + (#101 and #103 by @clue) + +* Feature: Forward compatibility with Cache 0.5 + (#102 by @clue) + +* Deprecate legacy `Query::$currentTime` and binary parser data attributes to clean up and simplify API. + (#99 by @clue) ## 0.4.13 (2018-02-27) diff --git a/README.md b/README.md index ed86667..05078c6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,11 @@ * [Basic usage](#basic-usage) * [Caching](#caching) * [Custom cache adapter](#custom-cache-adapter) +* [Resolver](#resolver) + * [resolve()](#resolve) + * [resolveAll()](#resolveall) * [Advanced usage](#advanced-usage) + * [UdpTransportExecutor](#udptransportexecutor) * [HostsFileExecutor](#hostsfileexecutor) * [Install](#install) * [Tests](#tests) @@ -57,14 +61,6 @@ Ideally, this method should thus be executed only once before the loop starts and not repeatedly while it is running. -Pending DNS queries can be cancelled by cancelling its pending promise like so: - -```php -$promise = $resolver->resolve('reactphp.org'); - -$promise->cancel(); -``` - But there's more. ## Caching @@ -115,46 +111,165 @@ See also the wiki for possible [cache implementations](https://github.com/reactphp/react/wiki/Users#cache-implementations). +## Resolver + +### resolve() + +The `resolve(string $domain): PromiseInterface` method can be used to +resolve the given $domain name to a single IPv4 address (type `A` query). + +```php +$resolver->resolve('reactphp.org')->then(function ($ip) { + echo 'IP for reactphp.org is ' . $ip . PHP_EOL; +}); +``` + +This is one of the main methods in this package. It sends a DNS query +for the given $domain name to your DNS server and returns a single IP +address on success. + +If the DNS server sends a DNS response message that contains more than +one IP address for this query, it will randomly pick one of the IP +addresses from the response. If you want the full list of IP addresses +or want to send a different type of query, you should use the +[`resolveAll()`](#resolveall) method instead. + +If the DNS server sends a DNS response message that indicates an error +code, this method will reject with a `RecordNotFoundException`. Its +message and code can be used to check for the response code. + +If the DNS communication fails and the server does not respond with a +valid response message, this message will reject with an `Exception`. + +Pending DNS queries can be cancelled by cancelling its pending promise like so: + +```php +$promise = $resolver->resolve('reactphp.org'); + +$promise->cancel(); +``` + +### resolveAll() + +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 +$resolver->resolveAll('reactphp.org', Message::TYPE_A)->then(function ($ips) { + echo 'IPv4 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; +}); + +$resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then(function ($ips) { + echo 'IPv6 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; +}); +``` + +This is one of the main methods in this package. It sends a DNS query +for the given $domain name to your DNS server and returns a list with all +record values on success. + +If the DNS server sends a DNS response message that contains one or more +records for this query, it will return a list with all record values +from the response. You can use the `Message::TYPE_*` constants to control +which type of query will be sent. Note that this method always returns a +list of record values, but each record value type depends on the query +type. For example, it returns the IPv4 addresses for type `A` queries, +the IPv6 addresses for type `AAAA` queries, the hostname for type `NS`, +`CNAME` and `PTR` queries and structured data for other queries. See also +the `Record` documentation for more details. + +If the DNS server sends a DNS response message that indicates an error +code, this method will reject with a `RecordNotFoundException`. Its +message and code can be used to check for the response code. + +If the DNS communication fails and the server does not respond with a +valid response message, this message will reject with an `Exception`. + +Pending DNS queries can be cancelled by cancelling its pending promise like so: + +```php +$promise = $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA); + +$promise->cancel(); +``` + ## Advanced Usage -For more advanced usages one can utilize the `React\Dns\Query\Executor` directly. +### UdpTransportExecutor + +The `UdpTransportExecutor` can be used to +send DNS queries over a UDP transport. + +This is the main class that sends a DNS query to your DNS server and is used +internally by the `Resolver` for the actual message transport. + +For more advanced usages one can utilize this class directly. The following example looks up the `IPv6` address for `igor.io`. ```php $loop = Factory::create(); - -$executor = new Executor($loop, new Parser(), new BinaryDumper(), null); +$executor = new UdpTransportExecutor($loop); $executor->query( '8.8.8.8:53', - new Query($name, Message::TYPE_AAAA, Message::CLASS_IN, time()) -)->done(function (Message $message) { + 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'); $loop->run(); - ``` See also the [fourth example](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 UdpTransportExecutor($loop), + 3.0, + $loop +); +``` + +Also note that this executor uses an unreliable UDP transport and that it +does not implement any retry logic, so you will likely want to use this in +combination with a `RetryExecutor` like this: + +```php +$executor = new RetryExecutor( + new TimeoutExecutor( + new UdpTransportExecutor($loop), + 3.0, + $loop + ) +); +``` + +> Internally, this class uses PHP's UDP sockets and does not take advantage + of [react/datagram](https://github.com/reactphp/datagram) purely for + organizational reasons to avoid a cyclic dependency between the two + packages. Higher-level components should take advantage of the Datagram + component instead of reimplementing this socket logic from scratch. + ### HostsFileExecutor -Note that the above `Executor` class always performs an actual DNS query. +Note that the above `UdpTransportExecutor` class always performs an actual DNS query. If you also want to take entries from your hosts file into account, you may use this code: ```php $hosts = \React\Dns\Config\HostsFile::loadFromPathBlocking(); -$executor = new Executor($loop, new Parser(), new BinaryDumper(), null); +$executor = new UdpTransportExecutor($loop); $executor = new HostsFileExecutor($hosts, $executor); $executor->query( '8.8.8.8:53', - new Query('localhost', Message::TYPE_A, Message::CLASS_IN, time()) + new Query('localhost', Message::TYPE_A, Message::CLASS_IN) ); ``` @@ -166,7 +281,7 @@ This will install the latest supported version: ```bash -$ composer require react/dns:^0.4.13 +$ composer require react/dns:^0.4.15 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. diff --git a/composer.json b/composer.json index 510a43c..40010c2 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "license": "MIT", "require": { "php": ">=5.3.0", - "react/cache": "~0.4.0|~0.3.0", + "react/cache": "^0.5 || ^0.4 || ^0.3", "react/event-loop": "^1.0 || ^0.5 || ^0.4 || ^0.3.5", "react/promise": "^2.1 || ^1.2.1", "react/promise-timer": "^1.2", diff --git a/examples/04-query-a-and-aaaa.php b/examples/04-query-a-and-aaaa.php deleted file mode 100644 index 6c46bbf..0000000 --- a/examples/04-query-a-and-aaaa.php +++ /dev/null @@ -1,32 +0,0 @@ -query('8.8.8.8:53', $ipv4Query)->done(function (Message $message) { - foreach ($message->answers as $answer) { - echo 'IPv4: ' . $answer->data . PHP_EOL; - } -}, 'printf'); -$executor->query('8.8.8.8:53', $ipv6Query)->done(function (Message $message) { - foreach ($message->answers as $answer) { - echo 'IPv6: ' . $answer->data . PHP_EOL; - } -}, 'printf'); - -$loop->run(); diff --git a/examples/11-all-ips.php b/examples/11-all-ips.php new file mode 100644 index 0000000..d118bbb --- /dev/null +++ b/examples/11-all-ips.php @@ -0,0 +1,31 @@ +nameservers ? reset($config->nameservers) : '8.8.8.8'; + +$factory = new Factory(); +$resolver = $factory->create($server, $loop); + +$name = isset($argv[1]) ? $argv[1] : 'www.google.com'; + +$resolver->resolveAll($name, Message::TYPE_A)->then(function (array $ips) use ($name) { + echo 'IPv4 addresses for ' . $name . ': ' . implode(', ', $ips) . PHP_EOL; +}, function (Exception $e) use ($name) { + echo 'No IPv4 addresses for ' . $name . ': ' . $e->getMessage() . PHP_EOL; +}); + +$resolver->resolveAll($name, Message::TYPE_AAAA)->then(function (array $ips) use ($name) { + echo 'IPv6 addresses for ' . $name . ': ' . implode(', ', $ips) . PHP_EOL; +}, function (Exception $e) use ($name) { + echo 'No IPv6 addresses for ' . $name . ': ' . $e->getMessage() . PHP_EOL; +}); + +$loop->run(); diff --git a/examples/12-all-types.php b/examples/12-all-types.php new file mode 100644 index 0000000..438ee86 --- /dev/null +++ b/examples/12-all-types.php @@ -0,0 +1,25 @@ +nameservers ? reset($config->nameservers) : '8.8.8.8'; + +$factory = new Factory(); +$resolver = $factory->create($server, $loop); + +$name = isset($argv[1]) ? $argv[1] : 'google.com'; +$type = constant('React\Dns\Model\Message::TYPE_' . (isset($argv[2]) ? $argv[2] : 'TXT')); + +$resolver->resolveAll($name, $type)->then(function (array $values) { + var_dump($values); +}, function (Exception $e) { + echo $e->getMessage() . PHP_EOL; +}); + +$loop->run(); diff --git a/examples/13-reverse-dns.php b/examples/13-reverse-dns.php new file mode 100644 index 0000000..7bc08f5 --- /dev/null +++ b/examples/13-reverse-dns.php @@ -0,0 +1,35 @@ +nameservers ? reset($config->nameservers) : '8.8.8.8'; + +$factory = new Factory(); +$resolver = $factory->create($server, $loop); + +$ip = isset($argv[1]) ? $argv[1] : '8.8.8.8'; + +if (@inet_pton($ip) === false) { + exit('Error: Given argument is not a valid IP' . PHP_EOL); +} + +if (strpos($ip, ':') === false) { + $name = inet_ntop(strrev(inet_pton($ip))) . '.in-addr.arpa'; +} else { + $name = wordwrap(strrev(bin2hex(inet_pton($ip))), 1, '.', true) . '.ip6.arpa'; +} + +$resolver->resolveAll($name, Message::TYPE_PTR)->then(function (array $names) { + var_dump($names); +}, function (Exception $e) { + echo $e->getMessage() . PHP_EOL; +}); + +$loop->run(); diff --git a/examples/91-query-a-and-aaaa.php b/examples/91-query-a-and-aaaa.php new file mode 100644 index 0000000..e4a3feb --- /dev/null +++ b/examples/91-query-a-and-aaaa.php @@ -0,0 +1,29 @@ +query('8.8.8.8:53', $ipv4Query)->then(function (Message $message) { + foreach ($message->answers as $answer) { + echo 'IPv4: ' . $answer->data . PHP_EOL; + } +}, 'printf'); +$executor->query('8.8.8.8:53', $ipv6Query)->then(function (Message $message) { + foreach ($message->answers as $answer) { + echo 'IPv6: ' . $answer->data . PHP_EOL; + } +}, 'printf'); + +$loop->run(); diff --git a/examples/92-query-any.php b/examples/92-query-any.php new file mode 100644 index 0000000..dcc14ae --- /dev/null +++ b/examples/92-query-any.php @@ -0,0 +1,71 @@ +query('8.8.8.8:53', $any)->then(function (Message $message) { + foreach ($message->answers as $answer) { + /* @var $answer Record */ + + $data = $answer->data; + + switch ($answer->type) { + case Message::TYPE_A: + $type = 'A'; + break; + case Message::TYPE_AAAA: + $type = 'AAAA'; + break; + case Message::TYPE_NS: + $type = 'NS'; + break; + case Message::TYPE_PTR: + $type = 'PTR'; + break; + case Message::TYPE_CNAME: + $type = 'CNAME'; + break; + case Message::TYPE_TXT: + // TXT records can contain a list of (binary) strings for each record. + // here, we assume this is printable ASCII and simply concatenate output + $type = 'TXT'; + $data = implode('', $data); + break; + case Message::TYPE_MX: + // MX records contain "priority" and "target", only dump its values here + $type = 'MX'; + $data = implode(' ', $data); + break; + case Message::TYPE_SRV: + // SRV records contains priority, weight, port and target, dump structure here + $type = 'SRV'; + $data = json_encode($data); + break; + case Message::TYPE_SOA: + // SOA records contain structured data, dump structure here + $type = 'SOA'; + $data = json_encode($data); + break; + default: + // unknown type uses HEX format + $type = 'Type ' . $answer->type; + $data = wordwrap(strtoupper(bin2hex($data)), 2, ' ', true); + } + + echo $type . ': ' . $data . PHP_EOL; + } +}, 'printf'); + +$loop->run(); diff --git a/src/Model/HeaderBag.php b/src/Model/HeaderBag.php index 193e65c..0093bd3 100644 --- a/src/Model/HeaderBag.php +++ b/src/Model/HeaderBag.php @@ -4,8 +4,6 @@ class HeaderBag { - public $data = ''; - public $attributes = array( 'qdCount' => 0, 'anCount' => 0, @@ -20,6 +18,11 @@ 'z' => 0, 'rcode' => Message::RCODE_OK, ); + + /** + * @deprecated unused, exists for BC only + */ + public $data = ''; public function get($name) { diff --git a/src/Model/Message.php b/src/Model/Message.php index 715cb1f..167344d 100644 --- a/src/Model/Message.php +++ b/src/Model/Message.php @@ -3,7 +3,6 @@ namespace React\Dns\Model; use React\Dns\Query\Query; -use React\Dns\Model\Record; class Message { @@ -15,6 +14,8 @@ const TYPE_MX = 15; const TYPE_TXT = 16; const TYPE_AAAA = 28; + const TYPE_SRV = 33; + const TYPE_ANY = 255; const CLASS_IN = 1; @@ -73,12 +74,31 @@ return $response; } + /** + * generates a random 16 bit message ID + * + * This uses a CSPRNG so that an outside attacker that is sending spoofed + * DNS response messages can not guess the message ID to avoid possible + * cache poisoning attacks. + * + * The `random_int()` function is only available on PHP 7+ or when + * https://github.com/paragonie/random_compat is installed. As such, using + * the latest supported PHP version is highly recommended. This currently + * falls back to a less secure random number generator on older PHP versions + * in the hope that this system is properly protected against outside + * attackers, for example by using one of the common local DNS proxy stubs. + * + * @return int + * @see self::getId() + * @codeCoverageIgnore + */ private static function generateId() { + if (function_exists('random_int')) { + return random_int(0, 0xffff); + } return mt_rand(0, 0xffff); } - - public $data = ''; public $header; public $questions = array(); @@ -86,6 +106,14 @@ public $authority = array(); public $additional = array(); + /** + * @deprecated still used internally for BC reasons, should not be used externally. + */ + public $data = ''; + + /** + * @deprecated still used internally for BC reasons, should not be used externally. + */ public $consumed = 0; public function __construct() @@ -93,6 +121,32 @@ $this->header = new HeaderBag(); } + /** + * Returns the 16 bit message ID + * + * The response message ID has to match the request message ID. This allows + * the receiver to verify this is the correct response message. An outside + * attacker may try to inject fake responses by "guessing" the message ID, + * so this should use a proper CSPRNG to avoid possible cache poisoning. + * + * @return int + * @see self::generateId() + */ + public function getId() + { + return $this->header->get('id'); + } + + /** + * Returns the response code (RCODE) + * + * @return int see self::RCODE_* constants + */ + public function getResponseCode() + { + return $this->header->get('rcode'); + } + public function prepare() { $this->header->populateCounts($this); diff --git a/src/Model/Record.php b/src/Model/Record.php index 029d232..035bc6e 100644 --- a/src/Model/Record.php +++ b/src/Model/Record.php @@ -4,10 +4,86 @@ class Record { + /** + * @var string hostname without trailing dot, for example "reactphp.org" + */ public $name; + + /** + * @var int see Message::TYPE_* constants (UINT16) + */ public $type; + + /** + * @var int see Message::CLASS_IN constant (UINT16) + */ public $class; + + /** + * @var int maximum TTL in seconds (UINT16) + */ public $ttl; + + /** + * The payload data for this record + * + * The payload data format depends on the record type. As a rule of thumb, + * this library will try to express this in a way that can be consumed + * easily without having to worry about DNS internals and its binary transport: + * + * - A: + * IPv4 address string, for example "192.168.1.1". + * - AAAA: + * IPv6 address string, for example "::1". + * - CNAME / PTR / NS: + * The hostname without trailing dot, for example "reactphp.org". + * - TXT: + * List of string values, for example `["v=spf1 include:example.com"]`. + * This is commonly a list with only a single string value, but this + * technically allows multiple strings (0-255 bytes each) in a single + * record. This is rarely used and depending on application you may want + * to join these together or handle them separately. Each string can + * transport any binary data, its character encoding is not defined (often + * ASCII/UTF-8 in practice). [RFC 1464](https://tools.ietf.org/html/rfc1464) + * suggests using key-value pairs such as `["name=test","version=1"]`, but + * interpretation of this is not enforced and left up to consumers of this + * library (used for DNS-SD/Zeroconf and others). + * - MX: + * Mail server priority (UINT16) and target hostname without trailing dot, + * for example `{"priority":10,"target":"mx.example.com"}`. + * The payload data uses an associative array with fixed keys "priority" + * (also commonly referred to as weight or preference) and "target" (also + * referred to as exchange). If a response message contains multiple + * records of this type, targets should be sorted by priority (lowest + * first) - this is left up to consumers of this library (used for SMTP). + * - SRV: + * Service priority (UINT16), service weight (UINT16), service port (UINT16) + * and target hostname without trailing dot, for example + * `{"priority":10,"weight":50,"port":8080,"target":"example.com"}`. + * The payload data uses an associative array with fixed keys "priority", + * "weight", "port" and "target" (also referred to as name). + * The target may be an empty host name string if the service is decidedly + * not available. If a response message contains multiple records of this + * type, targets should be sorted by priority (lowest first) and selected + * randomly according to their weight - this is left up to consumers of + * this library, see also [RFC 2782](https://tools.ietf.org/html/rfc2782) + * for more details. + * - SOA: + * Includes master hostname without trailing dot, responsible person email + * as hostname without trailing dot and serial, refresh, retry, expire and + * minimum times in seconds (UINT32 each), for example: + * `{"mname":"ns.example.com","rname":"hostmaster.example.com","serial": + * 2018082601,"refresh":3600,"retry":1800,"expire":60000,"minimum":3600}`. + * - Any other unknown type: + * An opaque binary string containing the RDATA as transported in the DNS + * record. For forwards compatibility, you should not rely on this format + * for unknown types. Future versions may add support for new types and + * this may then parse the payload data appropriately - this will not be + * considered a BC break. See the format definition of known types above + * for more details. + * + * @var string|string[]|array + */ public $data; public function __construct($name, $type, $class, $ttl = 0, $data = null) diff --git a/src/Protocol/Parser.php b/src/Protocol/Parser.php index 1191cd3..a503957 100644 --- a/src/Protocol/Parser.php +++ b/src/Protocol/Parser.php @@ -164,12 +164,56 @@ $consumed += $rdLength; $rdata = inet_ntop($ip); - } - - if (Message::TYPE_CNAME === $type || Message::TYPE_PTR === $type) { + } elseif (Message::TYPE_CNAME === $type || Message::TYPE_PTR === $type || Message::TYPE_NS === $type) { list($bodyLabels, $consumed) = $this->readLabels($message->data, $consumed); $rdata = implode('.', $bodyLabels); + } elseif (Message::TYPE_TXT === $type) { + $rdata = array(); + $remaining = $rdLength; + while ($remaining) { + $len = ord($message->data[$consumed]); + $rdata[] = substr($message->data, $consumed + 1, $len); + $consumed += $len + 1; + $remaining -= $len + 1; + } + } elseif (Message::TYPE_MX === $type) { + list($priority) = array_values(unpack('n', substr($message->data, $consumed, 2))); + list($bodyLabels, $consumed) = $this->readLabels($message->data, $consumed + 2); + + $rdata = array( + 'priority' => $priority, + 'target' => implode('.', $bodyLabels) + ); + } elseif (Message::TYPE_SRV === $type) { + list($priority, $weight, $port) = array_values(unpack('n*', substr($message->data, $consumed, 6))); + list($bodyLabels, $consumed) = $this->readLabels($message->data, $consumed + 6); + + $rdata = array( + 'priority' => $priority, + 'weight' => $weight, + 'port' => $port, + 'target' => implode('.', $bodyLabels) + ); + } elseif (Message::TYPE_SOA === $type) { + list($primaryLabels, $consumed) = $this->readLabels($message->data, $consumed); + list($mailLabels, $consumed) = $this->readLabels($message->data, $consumed); + list($serial, $refresh, $retry, $expire, $minimum) = array_values(unpack('N*', substr($message->data, $consumed, 20))); + $consumed += 20; + + $rdata = array( + 'mname' => implode('.', $primaryLabels), + 'rname' => implode('.', $mailLabels), + 'serial' => $serial, + 'refresh' => $refresh, + 'retry' => $retry, + 'expire' => $expire, + 'minimum' => $minimum + ); + } else { + // unknown types simply parse rdata as an opaque binary string + $rdata = substr($message->data, $consumed, $rdLength); + $consumed += $rdLength; } $message->consumed = $consumed; diff --git a/src/Query/Executor.php b/src/Query/Executor.php index 4c51f2b..40f6bb4 100644 --- a/src/Query/Executor.php +++ b/src/Query/Executor.php @@ -11,6 +11,10 @@ use React\Stream\DuplexResourceStream; use React\Stream\Stream; +/** + * @deprecated unused, exists for BC only + * @see UdpTransportExecutor + */ class Executor implements ExecutorInterface { private $loop; diff --git a/src/Query/Query.php b/src/Query/Query.php index aef6e05..058a78d 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -7,10 +7,24 @@ public $name; public $type; public $class; + + /** + * @deprecated still used internally for BC reasons, should not be used externally. + */ public $currentTime; - public function __construct($name, $type, $class, $currentTime) + /** + * @param string $name query name, i.e. hostname to look up + * @param int $type query type, see Message::TYPE_* constants + * @param int $class query class, see Message::CLASS_IN constant + * @param int|null $currentTime (deprecated) still used internally, should not be passed explicitly anymore. + */ + public function __construct($name, $type, $class, $currentTime = null) { + if($currentTime === null) { + $currentTime = time(); + } + $this->name = $name; $this->type = $type; $this->class = $class; diff --git a/src/Query/RecordCache.php b/src/Query/RecordCache.php index b8142d3..85eaffd 100644 --- a/src/Query/RecordCache.php +++ b/src/Query/RecordCache.php @@ -6,7 +6,11 @@ use React\Dns\Model\Message; use React\Dns\Model\Record; use React\Promise; +use React\Promise\PromiseInterface; +/** + * Wraps an underlying cache interface and exposes only cached DNS data + */ class RecordCache { private $cache; @@ -17,6 +21,13 @@ $this->cache = $cache; } + /** + * Looks up the cache if there's a cached answer for the given query + * + * @param Query $query + * @return PromiseInterface Promise resolves with array of Record objects on sucess + * or rejects with mixed values when query is not cached already. + */ public function lookup(Query $query) { $id = $this->serializeQueryToIdentity($query); @@ -26,8 +37,17 @@ return $this->cache ->get($id) ->then(function ($value) use ($query, $expiredAt) { + // cache 0.5+ resolves with null on cache miss, return explicit cache miss here + if ($value === null) { + return Promise\reject(); + } + + /* @var $recordBag RecordBag */ $recordBag = unserialize($value); + // reject this cache hit if the query was started before the time we expired the cache? + // todo: this is a legacy left over, this value is never actually set, so this never applies. + // todo: this should probably validate the cache time instead. if (null !== $expiredAt && $expiredAt <= $query->currentTime) { return Promise\reject(); } @@ -36,6 +56,13 @@ }); } + /** + * Stores all records from this response message in the cache + * + * @param int $currentTime + * @param Message $message + * @uses self::storeRecord() + */ public function storeResponseMessage($currentTime, Message $message) { foreach ($message->answers as $record) { @@ -43,6 +70,12 @@ } } + /** + * Stores a single record from a response message in the cache + * + * @param int $currentTime + * @param Record $record + */ public function storeRecord($currentTime, Record $record) { $id = $this->serializeRecordToIdentity($record); @@ -53,13 +86,21 @@ ->get($id) ->then( function ($value) { + if ($value === null) { + // cache 0.5+ cache miss resolves with null, return empty bag here + return new RecordBag(); + } + + // reuse existing bag on cache hit to append new record to it return unserialize($value); }, function ($e) { + // legacy cache < 0.5 cache miss rejects promise, return empty bag here return new RecordBag(); } ) - ->then(function ($recordBag) use ($id, $currentTime, $record, $cache) { + ->then(function (RecordBag $recordBag) use ($id, $currentTime, $record, $cache) { + // add a record to the existing (possibly empty) record bag and save to cache $recordBag->set($currentTime, $record); $cache->set($id, serialize($recordBag)); }); diff --git a/src/Query/UdpTransportExecutor.php b/src/Query/UdpTransportExecutor.php new file mode 100644 index 0000000..16d638c --- /dev/null +++ b/src/Query/UdpTransportExecutor.php @@ -0,0 +1,161 @@ +query( + * '8.8.8.8:53', + * 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'); + * + * $loop->run(); + * ``` + * + * See also the [fourth example](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 UdpTransportExecutor($loop), + * 3.0, + * $loop + * ); + * ``` + * + * Also note that this executor uses an unreliable UDP transport and that it + * does not implement any retry logic, so you will likely want to use this in + * combination with a `RetryExecutor` like this: + * + * ```php + * $executor = new RetryExecutor( + * new TimeoutExecutor( + * new UdpTransportExecutor($loop), + * 3.0, + * $loop + * ) + * ); + * ``` + * + * > Internally, this class uses PHP's UDP sockets and does not take advantage + * of [react/datagram](https://github.com/reactphp/datagram) purely for + * organizational reasons to avoid a cyclic dependency between the two + * packages. Higher-level components should take advantage of the Datagram + * component instead of reimplementing this socket logic from scratch. + */ +class UdpTransportExecutor implements ExecutorInterface +{ + private $loop; + private $parser; + private $dumper; + + /** + * @param LoopInterface $loop + * @param null|Parser $parser optional/advanced: DNS protocol parser to use + * @param null|BinaryDumper $dumper optional/advanced: DNS protocol dumper to use + */ + public function __construct(LoopInterface $loop, Parser $parser = null, BinaryDumper $dumper = null) + { + if ($parser === null) { + $parser = new Parser(); + } + if ($dumper === null) { + $dumper = new BinaryDumper(); + } + + $this->loop = $loop; + $this->parser = $parser; + $this->dumper = $dumper; + } + + public function query($nameserver, Query $query) + { + $request = Message::createRequestForQuery($query); + + $queryData = $this->dumper->toBinary($request); + if (isset($queryData[512])) { + return \React\Promise\reject(new \RuntimeException( + 'DNS query for ' . $query->name . ' failed: Query too large for UDP transport' + )); + } + + // UDP connections are instant, so try connection without a loop or timeout + $socket = @\stream_socket_client("udp://$nameserver", $errno, $errstr, 0); + if ($socket === false) { + return \React\Promise\reject(new \RuntimeException( + 'DNS query for ' . $query->name . ' failed: Unable to connect to DNS server (' . $errstr . ')', + $errno + )); + } + + // set socket to non-blocking and immediately try to send (fill write buffer) + \stream_set_blocking($socket, false); + \fwrite($socket, $queryData); + + $loop = $this->loop; + $deferred = new Deferred(function () use ($loop, $socket, $query) { + // cancellation should remove socket from loop and close socket + $loop->removeReadStream($socket); + \fclose($socket); + + throw new CancellationException('DNS query for ' . $query->name . ' has been cancelled'); + }); + + $parser = $this->parser; + $loop->addReadStream($socket, function ($socket) use ($loop, $deferred, $query, $parser, $request) { + // try to read a single data packet from the DNS server + // ignoring any errors, this is uses UDP packets and not a stream of data + $data = @\fread($socket, 512); + + try { + $response = $parser->parseMessage($data); + } catch (\Exception $e) { + // ignore and await next if we received an invalid message from remote server + // this may as well be a fake response from an attacker (possible DOS) + return; + } + + // ignore and await next if we received an unexpected response ID + // this may as well be a fake response from an attacker (possible cache poisoning) + if ($response->getId() !== $request->getId()) { + return; + } + + // we only react to the first valid message, so remove socket from loop and close + $loop->removeReadStream($socket); + \fclose($socket); + + if ($response->header->isTruncated()) { + $deferred->reject(new \RuntimeException('DNS query for ' . $query->name . ' failed: The server returned a truncated result for a UDP query, but retrying via TCP is currently not supported')); + return; + } + + $deferred->resolve($response); + }); + + return $deferred->promise(); + } +} diff --git a/src/Resolver/Factory.php b/src/Resolver/Factory.php index 12a912f..7b66e1d 100644 --- a/src/Resolver/Factory.php +++ b/src/Resolver/Factory.php @@ -5,15 +5,13 @@ use React\Cache\ArrayCache; use React\Cache\CacheInterface; use React\Dns\Config\HostsFile; -use React\Dns\Protocol\Parser; -use React\Dns\Protocol\BinaryDumper; use React\Dns\Query\CachedExecutor; -use React\Dns\Query\Executor; use React\Dns\Query\ExecutorInterface; use React\Dns\Query\HostsFileExecutor; use React\Dns\Query\RecordCache; use React\Dns\Query\RetryExecutor; use React\Dns\Query\TimeoutExecutor; +use React\Dns\Query\UdpTransportExecutor; use React\EventLoop\LoopInterface; class Factory @@ -71,7 +69,7 @@ protected function createExecutor(LoopInterface $loop) { return new TimeoutExecutor( - new Executor($loop, new Parser(), new BinaryDumper(), null), + new UdpTransportExecutor($loop), 5.0, $loop ); diff --git a/src/Resolver/Resolver.php b/src/Resolver/Resolver.php index 4a4983a..8690972 100644 --- a/src/Resolver/Resolver.php +++ b/src/Resolver/Resolver.php @@ -2,10 +2,11 @@ namespace React\Dns\Resolver; +use React\Dns\Model\Message; use React\Dns\Query\ExecutorInterface; use React\Dns\Query\Query; use React\Dns\RecordNotFoundException; -use React\Dns\Model\Message; +use React\Promise\PromiseInterface; class Resolver { @@ -18,59 +19,208 @@ $this->executor = $executor; } + /** + * Resolves the given $domain name to a single IPv4 address (type `A` query). + * + * ```php + * $resolver->resolve('reactphp.org')->then(function ($ip) { + * echo 'IP for reactphp.org is ' . $ip . PHP_EOL; + * }); + * ``` + * + * This is one of the main methods in this package. It sends a DNS query + * for the given $domain name to your DNS server and returns a single IP + * address on success. + * + * If the DNS server sends a DNS response message that contains more than + * one IP address for this query, it will randomly pick one of the IP + * addresses from the response. If you want the full list of IP addresses + * or want to send a different type of query, you should use the + * [`resolveAll()`](#resolveall) method instead. + * + * If the DNS server sends a DNS response message that indicates an error + * code, this method will reject with a `RecordNotFoundException`. Its + * message and code can be used to check for the response code. + * + * If the DNS communication fails and the server does not respond with a + * valid response message, this message will reject with an `Exception`. + * + * Pending DNS queries can be cancelled by cancelling its pending promise like so: + * + * ```php + * $promise = $resolver->resolve('reactphp.org'); + * + * $promise->cancel(); + * ``` + * + * @param string $domain + * @return PromiseInterface Returns a promise which resolves with a single IP address on success or + * rejects with an Exception on error. + */ public function resolve($domain) { - $query = new Query($domain, Message::TYPE_A, Message::CLASS_IN, time()); + return $this->resolveAll($domain, Message::TYPE_A)->then(function (array $ips) { + return $ips[array_rand($ips)]; + }); + } + + /** + * Resolves all record values for the given $domain name and query $type. + * + * ```php + * $resolver->resolveAll('reactphp.org', Message::TYPE_A)->then(function ($ips) { + * echo 'IPv4 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; + * }); + * + * $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then(function ($ips) { + * echo 'IPv6 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; + * }); + * ``` + * + * This is one of the main methods in this package. It sends a DNS query + * for the given $domain name to your DNS server and returns a list with all + * record values on success. + * + * If the DNS server sends a DNS response message that contains one or more + * records for this query, it will return a list with all record values + * from the response. You can use the `Message::TYPE_*` constants to control + * which type of query will be sent. Note that this method always returns a + * list of record values, but each record value type depends on the query + * type. For example, it returns the IPv4 addresses for type `A` queries, + * the IPv6 addresses for type `AAAA` queries, the hostname for type `NS`, + * `CNAME` and `PTR` queries and structured data for other queries. See also + * the `Record` documentation for more details. + * + * If the DNS server sends a DNS response message that indicates an error + * code, this method will reject with a `RecordNotFoundException`. Its + * message and code can be used to check for the response code. + * + * If the DNS communication fails and the server does not respond with a + * valid response message, this message will reject with an `Exception`. + * + * Pending DNS queries can be cancelled by cancelling its pending promise like so: + * + * ```php + * $promise = $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA); + * + * $promise->cancel(); + * ``` + * + * @param string $domain + * @return PromiseInterface Returns a promise which resolves with all record values on success or + * rejects with an Exception on error. + */ + public function resolveAll($domain, $type) + { + $query = new Query($domain, $type, Message::CLASS_IN); $that = $this; - return $this->executor - ->query($this->nameserver, $query) - ->then(function (Message $response) use ($query, $that) { - return $that->extractAddress($query, $response); - }); - } - + return $this->executor->query( + $this->nameserver, + $query + )->then(function (Message $response) use ($query, $that) { + return $that->extractValues($query, $response); + }); + } + + /** + * @deprecated unused, exists for BC only + */ public function extractAddress(Query $query, Message $response) { + $addresses = $this->extractValues($query, $response); + + return $addresses[array_rand($addresses)]; + } + + /** + * [Internal] extract all resource record values from response for this query + * + * @param Query $query + * @param Message $response + * @return array + * @throws RecordNotFoundException when response indicates an error or contains no data + * @internal + */ + public function extractValues(Query $query, Message $response) + { + // reject if response code indicates this is an error response message + $code = $response->getResponseCode(); + if ($code !== Message::RCODE_OK) { + switch ($code) { + case Message::RCODE_FORMAT_ERROR: + $message = 'Format Error'; + break; + case Message::RCODE_SERVER_FAILURE: + $message = 'Server Failure'; + break; + case Message::RCODE_NAME_ERROR: + $message = 'Non-Existent Domain / NXDOMAIN'; + break; + case Message::RCODE_NOT_IMPLEMENTED: + $message = 'Not Implemented'; + break; + case Message::RCODE_REFUSED: + $message = 'Refused'; + break; + default: + $message = 'Unknown error response code ' . $code; + } + throw new RecordNotFoundException( + 'DNS query for ' . $query->name . ' returned an error response (' . $message . ')', + $code + ); + } + $answers = $response->answers; - - $addresses = $this->resolveAliases($answers, $query->name); - + $addresses = $this->valuesByNameAndType($answers, $query->name, $query->type); + + // reject if we did not receive a valid answer (domain is valid, but no record for this type could be found) if (0 === count($addresses)) { - $message = 'DNS Request did not return valid answer.'; - throw new RecordNotFoundException($message); - } - - $address = $addresses[array_rand($addresses)]; - return $address; - } - + throw new RecordNotFoundException( + 'DNS query for ' . $query->name . ' did not return a valid answer (NOERROR / NODATA)' + ); + } + + return array_values($addresses); + } + + /** + * @deprecated unused, exists for BC only + */ public function resolveAliases(array $answers, $name) { + return $this->valuesByNameAndType($answers, $name, Message::TYPE_A); + } + + /** + * @param \React\Dns\Model\Record[] $answers + * @param string $name + * @param int $type + * @return array + */ + private function valuesByNameAndType(array $answers, $name, $type) + { + // return all record values for this name and type (if any) $named = $this->filterByName($answers, $name); - $aRecords = $this->filterByType($named, Message::TYPE_A); + $records = $this->filterByType($named, $type); + if ($records) { + return $this->mapRecordData($records); + } + + // no matching records found? check if there are any matching CNAMEs instead $cnameRecords = $this->filterByType($named, Message::TYPE_CNAME); - - if ($aRecords) { - return $this->mapRecordData($aRecords); - } - if ($cnameRecords) { - $aRecords = array(); - $cnames = $this->mapRecordData($cnameRecords); foreach ($cnames as $cname) { - $targets = $this->filterByName($answers, $cname); - $aRecords = array_merge( - $aRecords, - $this->resolveAliases($answers, $cname) + $records = array_merge( + $records, + $this->valuesByNameAndType($answers, $cname, $type) ); } - - return $aRecords; - } - - return array(); + } + + return $records; } private function filterByName(array $answers, $name) diff --git a/tests/FunctionalResolverTest.php b/tests/FunctionalResolverTest.php index 0807e86..899e120 100644 --- a/tests/FunctionalResolverTest.php +++ b/tests/FunctionalResolverTest.php @@ -2,10 +2,10 @@ namespace React\Tests\Dns; -use React\Tests\Dns\TestCase; use React\EventLoop\Factory as LoopFactory; -use React\Dns\Resolver\Resolver; use React\Dns\Resolver\Factory; +use React\Dns\RecordNotFoundException; +use React\Dns\Model\Message; class FunctionalTest extends TestCase { @@ -21,6 +21,14 @@ { $promise = $this->resolver->resolve('localhost'); $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); + + $this->loop->run(); + } + + public function testResolveAllLocalhostResolvesWithArray() + { + $promise = $this->resolver->resolveAll('localhost', Message::TYPE_A); + $promise->then($this->expectCallableOnceWith($this->isType('array')), $this->expectCallableNever()); $this->loop->run(); } @@ -41,16 +49,24 @@ */ public function testResolveInvalidRejects() { + $ex = $this->callback(function ($param) { + return ($param instanceof RecordNotFoundException && $param->getCode() === Message::RCODE_NAME_ERROR); + }); + $promise = $this->resolver->resolve('example.invalid'); - $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); + $promise->then($this->expectCallableNever(), $this->expectCallableOnceWith($ex)); $this->loop->run(); } public function testResolveCancelledRejectsImmediately() { + $ex = $this->callback(function ($param) { + return ($param instanceof \RuntimeException && $param->getMessage() === 'DNS query for google.com has been cancelled'); + }); + $promise = $this->resolver->resolve('google.com'); - $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); + $promise->then($this->expectCallableNever(), $this->expectCallableOnceWith($ex)); $promise->cancel(); $time = microtime(true); diff --git a/tests/Model/MessageTest.php b/tests/Model/MessageTest.php index 53d6b28..cf3d890 100644 --- a/tests/Model/MessageTest.php +++ b/tests/Model/MessageTest.php @@ -26,5 +26,6 @@ $this->assertFalse($request->header->isQuery()); $this->assertTrue($request->header->isResponse()); $this->assertEquals(0, $request->header->get('anCount')); + $this->assertEquals(Message::RCODE_OK, $request->getResponseCode()); } } diff --git a/tests/Protocol/ParserTest.php b/tests/Protocol/ParserTest.php index 195fad2..2bba482 100644 --- a/tests/Protocol/ParserTest.php +++ b/tests/Protocol/ParserTest.php @@ -157,6 +157,31 @@ $this->assertSame('178.79.169.131', $response->answers[0]->data); } + public function testParseAnswerWithUnknownType() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "23 28 00 01"; // answer: type 9000, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 05"; // answer: rdlength 5 + $data .= "68 65 6c 6c 6f"; // answer: rdata "hello" + + $data = $this->convertTcpDumpToBinary($data); + + $response = new Message(); + $response->header->set('anCount', 1); + $response->data = $data; + + $this->parser->parseAnswer($response); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(9000, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(86400, $response->answers[0]->ttl); + $this->assertSame('hello', $response->answers[0]->data); + } + public function testParseResponseWithCnameAndOffsetPointers() { $data = ""; @@ -231,6 +256,114 @@ $this->assertSame('2a00:1450:4009:809::200e', $response->answers[0]->data); } + public function testParseTXTResponse() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 10 00 01"; // answer: type TXT, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 06"; // answer: rdlength 6 + $data .= "05 68 65 6c 6c 6f"; // answer: rdata length 5: hello + + $data = $this->convertTcpDumpToBinary($data); + + $response = new Message(); + $response->header->set('anCount', 1); + $response->data = $data; + + $this->parser->parseAnswer($response); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(Message::TYPE_TXT, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(86400, $response->answers[0]->ttl); + $this->assertSame(array('hello'), $response->answers[0]->data); + } + + public function testParseTXTResponseMultiple() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 10 00 01"; // answer: type TXT, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 0C"; // answer: rdlength 12 + $data .= "05 68 65 6c 6c 6f 05 77 6f 72 6c 64"; // answer: rdata length 5: hello, length 5: world + + $data = $this->convertTcpDumpToBinary($data); + + $response = new Message(); + $response->header->set('anCount', 1); + $response->data = $data; + + $this->parser->parseAnswer($response); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(Message::TYPE_TXT, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(86400, $response->answers[0]->ttl); + $this->assertSame(array('hello', 'world'), $response->answers[0]->data); + } + + public function testParseMXResponse() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 0f 00 01"; // answer: type MX, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 09"; // answer: rdlength 9 + $data .= "00 0a 05 68 65 6c 6c 6f 00"; // answer: rdata priority 10: hello + + $data = $this->convertTcpDumpToBinary($data); + + $response = new Message(); + $response->header->set('anCount', 1); + $response->data = $data; + + $this->parser->parseAnswer($response); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(Message::TYPE_MX, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(86400, $response->answers[0]->ttl); + $this->assertSame(array('priority' => 10, 'target' => 'hello'), $response->answers[0]->data); + } + + public function testParseSRVResponse() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 21 00 01"; // answer: type SRV, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 0C"; // answer: rdlength 12 + $data .= "00 0a 00 14 1F 90 04 74 65 73 74 00"; // answer: rdata priority 10, weight 20, port 8080 test + + $data = $this->convertTcpDumpToBinary($data); + + $response = new Message(); + $response->header->set('anCount', 1); + $response->data = $data; + + $this->parser->parseAnswer($response); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(Message::TYPE_SRV, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(86400, $response->answers[0]->ttl); + $this->assertSame( + array( + 'priority' => 10, + 'weight' => 20, + 'port' => 8080, + 'target' => 'test' + ), + $response->answers[0]->data + ); + } + public function testParseResponseWithTwoAnswers() { $data = ""; @@ -273,6 +406,70 @@ $this->assertSame('193.223.78.152', $response->answers[1]->data); } + public function testParseNSResponse() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 02 00 01"; // answer: type NS, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 07"; // answer: rdlength 7 + $data .= "05 68 65 6c 6c 6f 00"; // answer: rdata hello + + $data = $this->convertTcpDumpToBinary($data); + + $response = new Message(); + $response->header->set('anCount', 1); + $response->data = $data; + + $this->parser->parseAnswer($response); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(Message::TYPE_NS, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(86400, $response->answers[0]->ttl); + $this->assertSame('hello', $response->answers[0]->data); + } + + public function testParseSOAResponse() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 06 00 01"; // answer: type SOA, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 07"; // answer: rdlength 7 + $data .= "02 6e 73 05 68 65 6c 6c 6f 00"; // answer: rdata ns.hello (mname) + $data .= "01 65 05 68 65 6c 6c 6f 00"; // answer: rdata e.hello (rname) + $data .= "78 49 28 D5 00 00 2a 30 00 00 0e 10"; // answer: rdata 2018060501, 10800, 3600 + $data .= "00 09 3a 80 00 00 0e 10"; // answer: 605800, 3600 + + $data = $this->convertTcpDumpToBinary($data); + + $response = new Message(); + $response->header->set('anCount', 1); + $response->data = $data; + + $this->parser->parseAnswer($response); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(Message::TYPE_SOA, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(86400, $response->answers[0]->ttl); + $this->assertSame( + array( + 'mname' => 'ns.hello', + 'rname' => 'e.hello', + 'serial' => 2018060501, + 'refresh' => 10800, + 'retry' => 3600, + 'expire' => 604800, + 'minimum' => 3600 + ), + $response->answers[0]->data + ); + } + public function testParsePTRResponse() { $data = ""; diff --git a/tests/Query/RecordCacheTest.php b/tests/Query/RecordCacheTest.php index 399fbe8..263db83 100644 --- a/tests/Query/RecordCacheTest.php +++ b/tests/Query/RecordCacheTest.php @@ -9,21 +9,91 @@ use React\Dns\Query\RecordCache; use React\Dns\Query\Query; use React\Promise\PromiseInterface; +use React\Promise\Promise; class RecordCacheTest extends TestCase { /** - * @covers React\Dns\Query\RecordCache - * @test - */ - public function lookupOnEmptyCacheShouldReturnNull() + * @covers React\Dns\Query\RecordCache + * @test + */ + public function lookupOnNewCacheMissShouldReturnNull() { $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $cache = new RecordCache(new ArrayCache()); + $base = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $base->expects($this->once())->method('get')->willReturn(\React\Promise\resolve(null)); + + $cache = new RecordCache($base); $promise = $cache->lookup($query); $this->assertInstanceOf('React\Promise\RejectedPromise', $promise); + } + + /** + * @covers React\Dns\Query\RecordCache + * @test + */ + public function lookupOnLegacyCacheMissShouldReturnNull() + { + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); + + $base = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $base->expects($this->once())->method('get')->willReturn(\React\Promise\reject()); + + $cache = new RecordCache($base); + $promise = $cache->lookup($query); + + $this->assertInstanceOf('React\Promise\RejectedPromise', $promise); + } + + /** + * @covers React\Dns\Query\RecordCache + * @test + */ + public function storeRecordPendingCacheDoesNotSetCache() + { + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); + $pending = new Promise(function () { }); + + $base = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $base->expects($this->once())->method('get')->willReturn($pending); + $base->expects($this->never())->method('set'); + + $cache = new RecordCache($base); + $cache->storeRecord($query->currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131')); + } + + /** + * @covers React\Dns\Query\RecordCache + * @test + */ + public function storeRecordOnNewCacheMissSetsCache() + { + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); + + $base = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $base->expects($this->once())->method('get')->willReturn(\React\Promise\resolve(null)); + $base->expects($this->once())->method('set')->with($this->isType('string'), $this->isType('string')); + + $cache = new RecordCache($base); + $cache->storeRecord($query->currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131')); + } + + /** + * @covers React\Dns\Query\RecordCache + * @test + */ + public function storeRecordOnOldCacheMissSetsCache() + { + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); + + $base = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $base->expects($this->once())->method('get')->willReturn(\React\Promise\reject()); + $base->expects($this->once())->method('set')->with($this->isType('string'), $this->isType('string')); + + $cache = new RecordCache($base); + $cache->storeRecord($query->currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131')); } /** diff --git a/tests/Query/UdpTransportExecutorTest.php b/tests/Query/UdpTransportExecutorTest.php new file mode 100644 index 0000000..f7222dc --- /dev/null +++ b/tests/Query/UdpTransportExecutorTest.php @@ -0,0 +1,215 @@ +getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addReadStream'); + + $dumper = $this->getMockBuilder('React\Dns\Protocol\BinaryDumper')->getMock(); + $dumper->expects($this->once())->method('toBinary')->willReturn(str_repeat('.', 513)); + + $executor = new UdpTransportExecutor($loop, null, $dumper); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + $promise = $executor->query('8.8.8.8:53', $query); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testQueryRejectsIfServerConnectionFails() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addReadStream'); + + $executor = new UdpTransportExecutor($loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + $promise = $executor->query('///', $query); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + $promise->then(null, $this->expectCallableOnce()); + } + + /** + * @group internet + */ + public function testQueryRejectsOnCancellation() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addReadStream'); + $loop->expects($this->once())->method('removeReadStream'); + + $executor = new UdpTransportExecutor($loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + $promise = $executor->query('8.8.8.8:53', $query); + $promise->cancel(); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testQueryKeepsPendingIfServerRejectsNetworkPacket() + { + $loop = Factory::create(); + + $executor = new UdpTransportExecutor($loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $wait = true; + $promise = $executor->query('127.0.0.1:1', $query)->then( + null, + function ($e) use (&$wait) { + $wait = false; + throw $e; + } + ); + + \Clue\React\Block\sleep(0.2, $loop); + $this->assertTrue($wait); + } + + public function testQueryKeepsPendingIfServerSendInvalidMessage() + { + $loop = Factory::create(); + + $server = stream_socket_server('udp://127.0.0.1:0', $errno, $errstr, STREAM_SERVER_BIND); + $loop->addReadStream($server, function ($server) { + $data = stream_socket_recvfrom($server, 512, 0, $peer); + stream_socket_sendto($server, 'invalid', 0, $peer); + }); + + $address = stream_socket_get_name($server, false); + $executor = new UdpTransportExecutor($loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $wait = true; + $promise = $executor->query($address, $query)->then( + null, + function ($e) use (&$wait) { + $wait = false; + throw $e; + } + ); + + \Clue\React\Block\sleep(0.2, $loop); + $this->assertTrue($wait); + } + + public function testQueryKeepsPendingIfServerSendInvalidId() + { + $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) { + $data = stream_socket_recvfrom($server, 512, 0, $peer); + + $message = $parser->parseMessage($data); + $message->header->set('id', 0); + + stream_socket_sendto($server, $dumper->toBinary($message), 0, $peer); + }); + + $address = stream_socket_get_name($server, false); + $executor = new UdpTransportExecutor($loop, $parser, $dumper); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $wait = true; + $promise = $executor->query($address, $query)->then( + null, + function ($e) use (&$wait) { + $wait = false; + throw $e; + } + ); + + \Clue\React\Block\sleep(0.2, $loop); + $this->assertTrue($wait); + } + + 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) { + $data = stream_socket_recvfrom($server, 512, 0, $peer); + + $message = $parser->parseMessage($data); + $message->header->set('tc', 1); + + stream_socket_sendto($server, $dumper->toBinary($message), 0, $peer); + }); + + $address = stream_socket_get_name($server, false); + $executor = new UdpTransportExecutor($loop, $parser, $dumper); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $wait = true; + $promise = $executor->query($address, $query)->then( + null, + function ($e) use (&$wait) { + $wait = false; + throw $e; + } + ); + + // run loop for short period to ensure we detect connection ICMP rejection error + \Clue\React\Block\sleep(0.01, $loop); + if ($wait) { + \Clue\React\Block\sleep(0.2, $loop); + } + + $this->assertFalse($wait); + } + + 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) { + $data = stream_socket_recvfrom($server, 512, 0, $peer); + + $message = $parser->parseMessage($data); + + stream_socket_sendto($server, $dumper->toBinary($message), 0, $peer); + }); + + $address = stream_socket_get_name($server, false); + $executor = new UdpTransportExecutor($loop, $parser, $dumper); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($address, $query); + $response = \Clue\React\Block\await($promise, $loop, 0.2); + + $this->assertInstanceOf('React\Dns\Model\Message', $response); + } +} diff --git a/tests/Resolver/ResolveAliasesTest.php b/tests/Resolver/ResolveAliasesTest.php index b5175e3..a9b8608 100644 --- a/tests/Resolver/ResolveAliasesTest.php +++ b/tests/Resolver/ResolveAliasesTest.php @@ -12,6 +12,7 @@ { /** * @covers React\Dns\Resolver\Resolver::resolveAliases + * @covers React\Dns\Resolver\Resolver::valuesByNameAndType * @dataProvider provideAliasedAnswers */ public function testResolveAliases(array $expectedAnswers, array $answers, $name) diff --git a/tests/Resolver/ResolverTest.php b/tests/Resolver/ResolverTest.php index e11509b..661386d 100644 --- a/tests/Resolver/ResolverTest.php +++ b/tests/Resolver/ResolverTest.php @@ -8,6 +8,7 @@ use React\Dns\Model\Record; use React\Promise; use React\Tests\Dns\TestCase; +use React\Dns\RecordNotFoundException; class ResolverTest extends TestCase { @@ -33,6 +34,75 @@ } /** @test */ + public function resolveAllShouldQueryGivenRecords() + { + $executor = $this->createExecutorMock(); + $executor + ->expects($this->once()) + ->method('query') + ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($nameserver, $query) { + $response = new Message(); + $response->header->set('qr', 1); + $response->questions[] = new Record($query->name, $query->type, $query->class); + $response->answers[] = new Record($query->name, $query->type, $query->class, 3600, '::1'); + + return Promise\resolve($response); + })); + + $resolver = new Resolver('8.8.8.8:53', $executor); + $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then($this->expectCallableOnceWith(array('::1'))); + } + + /** @test */ + public function resolveAllShouldIgnoreRecordsWithOtherTypes() + { + $executor = $this->createExecutorMock(); + $executor + ->expects($this->once()) + ->method('query') + ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($nameserver, $query) { + $response = new Message(); + $response->header->set('qr', 1); + $response->questions[] = new Record($query->name, $query->type, $query->class); + $response->answers[] = new Record($query->name, Message::TYPE_TXT, $query->class, 3600, array('ignored')); + $response->answers[] = new Record($query->name, $query->type, $query->class, 3600, '::1'); + + return Promise\resolve($response); + })); + + $resolver = new Resolver('8.8.8.8:53', $executor); + $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then($this->expectCallableOnceWith(array('::1'))); + } + + /** @test */ + public function resolveAllShouldReturnMultipleValuesForAlias() + { + $executor = $this->createExecutorMock(); + $executor + ->expects($this->once()) + ->method('query') + ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($nameserver, $query) { + $response = new Message(); + $response->header->set('qr', 1); + $response->questions[] = new Record($query->name, $query->type, $query->class); + $response->answers[] = new Record($query->name, Message::TYPE_CNAME, $query->class, 3600, 'example.com'); + $response->answers[] = new Record('example.com', $query->type, $query->class, 3600, '::1'); + $response->answers[] = new Record('example.com', $query->type, $query->class, 3600, '::2'); + $response->prepare(); + + return Promise\resolve($response); + })); + + $resolver = new Resolver('8.8.8.8:53', $executor); + $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then( + $this->expectCallableOnceWith($this->equalTo(array('::1', '::2'))) + ); + } + + /** @test */ public function resolveShouldQueryARecordsAndIgnoreCase() { $executor = $this->createExecutorMock(); @@ -66,28 +136,6 @@ $response->header->set('qr', 1); $response->questions[] = new Record($query->name, $query->type, $query->class); $response->answers[] = new Record('foo.bar', $query->type, $query->class, 3600, '178.79.169.131'); - - return Promise\resolve($response); - })); - - $errback = $this->expectCallableOnceWith($this->isInstanceOf('React\Dns\RecordNotFoundException')); - - $resolver = new Resolver('8.8.8.8:53', $executor); - $resolver->resolve('igor.io')->then($this->expectCallableNever(), $errback); - } - - /** @test */ - public function resolveWithNoAnswersShouldThrowException() - { - $executor = $this->createExecutorMock(); - $executor - ->expects($this->once()) - ->method('query') - ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($nameserver, $query) { - $response = new Message(); - $response->header->set('qr', 1); - $response->questions[] = new Record($query->name, $query->type, $query->class); return Promise\resolve($response); })); @@ -116,12 +164,86 @@ return Promise\resolve($response); })); - $errback = $this->expectCallableOnceWith($this->isInstanceOf('React\Dns\RecordNotFoundException')); + $errback = $this->expectCallableOnceWith($this->callback(function ($param) { + return ($param instanceof RecordNotFoundException && $param->getCode() === 0 && $param->getMessage() === 'DNS query for igor.io did not return a valid answer (NOERROR / NODATA)'); + })); $resolver = new Resolver('8.8.8.8:53', $executor); $resolver->resolve('igor.io')->then($this->expectCallableNever(), $errback); } + public function provideRcodeErrors() + { + return array( + array( + Message::RCODE_FORMAT_ERROR, + 'DNS query for example.com returned an error response (Format Error)', + ), + array( + Message::RCODE_SERVER_FAILURE, + 'DNS query for example.com returned an error response (Server Failure)', + ), + array( + Message::RCODE_NAME_ERROR, + 'DNS query for example.com returned an error response (Non-Existent Domain / NXDOMAIN)' + ), + array( + Message::RCODE_NOT_IMPLEMENTED, + 'DNS query for example.com returned an error response (Not Implemented)' + ), + array( + Message::RCODE_REFUSED, + 'DNS query for example.com returned an error response (Refused)' + ), + array( + 99, + 'DNS query for example.com returned an error response (Unknown error response code 99)' + ) + ); + } + + /** + * @test + * @dataProvider provideRcodeErrors + */ + public function resolveWithRcodeErrorShouldCallErrbackIfGiven($code, $expectedMessage) + { + $executor = $this->createExecutorMock(); + $executor + ->expects($this->once()) + ->method('query') + ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($nameserver, $query) use ($code) { + $response = new Message(); + $response->header->set('qr', 1); + $response->header->set('rcode', $code); + $response->questions[] = new Record($query->name, $query->type, $query->class); + + return Promise\resolve($response); + })); + + $errback = $this->expectCallableOnceWith($this->callback(function ($param) use ($code, $expectedMessage) { + return ($param instanceof RecordNotFoundException && $param->getCode() === $code && $param->getMessage() === $expectedMessage); + })); + + $resolver = new Resolver('8.8.8.8:53', $executor); + $resolver->resolve('example.com')->then($this->expectCallableNever(), $errback); + } + + public function testLegacyExtractAddress() + { + $executor = $this->createExecutorMock(); + $resolver = new Resolver('8.8.8.8:53', $executor); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $response = Message::createResponseWithAnswersForQuery($query, array( + new Record('reactphp.org', Message::TYPE_A, Message::CLASS_IN, 3600, '1.2.3.4') + )); + + $ret = $resolver->extractAddress($query, $response); + $this->assertEquals('1.2.3.4', $ret); + } + private function createExecutorMock() { return $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock();