commit 6f0b89aa75e8e656504bbc5733c727a6f491e788 Author: Daniil Gentili Date: Mon Jun 10 19:32:28 2019 +0200 First commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8a6a2d7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +/test export-ignore +/examples export-ignore +/.coveralls.yml export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php_cs export-ignore +/.travis.yml export-ignore +/phpunit.xml export-ignore +docs/asset export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..210bf24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build +composer.lock +phpunit.xml +vendor +.php_cs.cache +coverage diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ef2bec4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "docs/.shared"] + path = docs/.shared + url = https://github.com/amphp/amphp.github.io diff --git a/.php_cs.dist b/.php_cs.dist new file mode 100644 index 0000000..8d02bce --- /dev/null +++ b/.php_cs.dist @@ -0,0 +1,13 @@ +getFinder() + ->in(__DIR__ . '/examples') + ->in(__DIR__ . '/lib') + ->in(__DIR__ . '/test'); + +$cacheDir = getenv('TRAVIS') ? getenv('HOME') . '/.php-cs-fixer' : __DIR__; + +$config->setCacheFile($cacheDir . '/.php_cs.cache'); + +return $config; diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..71b9734 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,39 @@ +sudo: false + +language: php + +php: + - 7.0 + - 7.1 + - 7.2 + - 7.3 + - nightly + +matrix: + allow_failures: + - php: nightly + fast_finish: true + +env: + - AMP_DEBUG=true + +before_install: + - phpenv config-rm xdebug.ini || echo "No xdebug config." + +install: + - composer update -n --prefer-dist + - wget https://github.com/php-coveralls/php-coveralls/releases/download/v1.0.2/coveralls.phar + - chmod +x coveralls.phar + +script: + - vendor/bin/phpunit --coverage-text --coverage-clover build/logs/clover.xml + - PHP_CS_FIXER_IGNORE_ENV=1 php vendor/bin/php-cs-fixer --diff --dry-run -v fix + +after_script: + - ./coveralls.phar -v + +cache: + directories: + - $HOME/.composer/cache + - $HOME/.php-cs-fixer + - $HOME/.local diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cee345a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +## Submitting useful bug reports + +Please search existing issues first to make sure this is not a duplicate. +Every issue report has a cost for the developers required to field it; be +respectful of others' time and ensure your report isn't spurious prior to +submission. Please adhere to [sound bug reporting principles](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html). + +## Development ideology + +Truths which we believe to be self-evident: + +- **It's an asynchronous world.** Be wary of anything that undermines + async principles. + +- **The answer is not more options.** If you feel compelled to expose + new preferences to the user it's very possible you've made a wrong + turn somewhere. + +- **There are no power users.** The idea that some users "understand" + concepts better than others has proven to be, for the most part, false. + If anything, "power users" are more dangerous than the rest, and we + should avoid exposing dangerous functionality to them. + +## Code style + +The amphp project adheres to the PSR-2 style guide with the exception that +opening braces for classes and methods must appear on the same line as +the declaration: + +https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..88c2300 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2017 amphp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6c3291b --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +PHP_BIN := php +COMPOSER_BIN := composer + +COVERAGE = coverage +SRCS = lib test + +find_php_files = $(shell find $(1) -type f -name "*.php") +src = $(foreach d,$(SRCS),$(call find_php_files,$(d))) + +.PHONY: test +test: setup phpunit code-style + +.PHONY: clean +clean: clean-coverage clean-vendor + +.PHONY: clean-coverage +clean-coverage: + test ! -e coverage || rm -r coverage + +.PHONY: clean-vendor +clean-vendor: + test ! -e vendor || rm -r vendor + +.PHONY: setup +setup: vendor/autoload.php + +.PHONY: deps-update +deps-update: + $(COMPOSER_BIN) update + +.PHONY: phpunit +phpunit: setup + $(PHP_BIN) vendor/bin/phpunit + +.PHONY: code-style +code-style: setup + PHP_CS_FIXER_IGNORE_ENV=1 $(PHP_BIN) vendor/bin/php-cs-fixer --diff -v fix + +composer.lock: composer.json + $(COMPOSER_BIN) install + touch $@ + +vendor/autoload.php: composer.lock + $(COMPOSER_BIN) install + touch $@ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f0259d --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# dns + +[![Build Status](https://img.shields.io/travis/amphp/dns/master.svg?style=flat-square)](https://travis-ci.org/amphp/dns) +[![CoverageStatus](https://img.shields.io/coveralls/amphp/dns/master.svg?style=flat-square)](https://coveralls.io/github/amphp/dns?branch=master) +![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square) + +`amphp/dns` provides asynchronous DNS name resolution for [Amp](https://github.com/amphp/amp). + +## Installation + +```bash +composer require amphp/dns +``` + +## Example + +```php + appveyor.yml + +init: + - SET PATH=C:\Program Files\OpenSSL;c:\tools\php73;%PATH% + - SET COMPOSER_NO_INTERACTION=1 + - SET PHP=1 + - SET ANSICON=121x90 (121x90) + +install: + - IF EXIST c:\tools\php73 (SET PHP=0) + - IF %PHP%==1 sc config wuauserv start= auto + - IF %PHP%==1 net start wuauserv + - IF %PHP%==1 cinst -y OpenSSL.Light + - IF %PHP%==1 cinst -y php + - cd c:\tools\php73 + - IF %PHP%==1 copy php.ini-production php.ini /Y + - IF %PHP%==1 echo date.timezone="UTC" >> php.ini + - IF %PHP%==1 echo extension_dir=ext >> php.ini + - IF %PHP%==1 echo extension=php_openssl.dll >> php.ini + - IF %PHP%==1 echo extension=php_mbstring.dll >> php.ini + - IF %PHP%==1 echo extension=php_fileinfo.dll >> php.ini + - cd c:\projects\amphp + - appveyor DownloadFile https://getcomposer.org/composer.phar + - php composer.phar install --prefer-dist --no-progress + +test_script: + - cd c:\projects\amphp + - vendor/bin/phpunit --colors=always diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0b35ad1 --- /dev/null +++ b/composer.json @@ -0,0 +1,87 @@ +{ + "name": "amphp/dns-over-https", + "homepage": "https://github.com/amphp/dns-over-https", + "description": "Async DNS-over-HTTPS resolution for Amp.", + "keywords": [ + "dns", + "doh", + "dns-over-https", + "https", + "resolve", + "client", + "async", + "amp", + "amphp" + ], + "license": "MIT", + "authors": [ + { + "name": "Daniil Gentili", + "email": "daniil@daniil.it" + }, + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "require": { + "php": ">=7.0", + "amphp/cache": "^1.2", + "amphp/parser": "^1", + "daverandom/libdns": "^2.0.1", + "amphp/amp": "^2", + "amphp/artax": "dev-master", + "amphp/dns": "dev-master as v0.9.x-dev", + "ext-filter": "*" + }, + "require-dev": { + "amphp/phpunit-util": "^1", + "phpunit/phpunit": "^6", + "amphp/php-cs-fixer-config": "dev-master" + }, + "autoload": { + "psr-4": { + "Amp\\DoH\\": "lib" + }, + "files": [ + "lib/functions.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Amp\\DoH\\Test\\": "test" + } + }, + "scripts": { + "check": [ + "@cs", + "@test" + ], + "cs": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix -v --diff --dry-run", + "cs-fix": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix -v --diff", + "test": "@php -dzend.assertions=1 -dassert.exception=1 ./vendor/bin/phpunit --coverage-text" + }, + "minimum-stability": "dev", + "repositories": [ + { + "type": "git", + "url": "https://github.com/danog/phpseclib" + } + ] +} diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..34f6bef --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,3 @@ +.bundle +_site +Gemfile.lock diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 0000000..ada6383 --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" +gem "github-pages" +gem "kramdown" +gem "jekyll-github-metadata" +gem "jekyll-relative-links" diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..34607c7 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,26 @@ +kramdown: + input: GFM + toc_levels: 2..3 + +baseurl: "/dns" +layouts_dir: ".shared/layout" +includes_dir: ".shared/includes" + +exclude: ["Gemfile", "Gemfile.lock", "README.md", "vendor"] +safe: true + +repository: amphp/dns +gems: + - "jekyll-github-metadata" + - "jekyll-relative-links" + +defaults: + - scope: + path: "" + type: "pages" + values: + layout: "docs" + description: "amphp/dns provides asynchronous DNS resolution for Amp." + keywords: ["amphp", "amp", "dns", "name resolution", "async", "php"] + +shared_asset_path: "/dns/asset" diff --git a/docs/asset b/docs/asset new file mode 120000 index 0000000..1d3b8c6 --- /dev/null +++ b/docs/asset @@ -0,0 +1 @@ +.shared/asset \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..dcc9ea4 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,73 @@ +--- +title: Asynchronous DNS Resolution +permalink: / +--- +`amphp/dns` provides asynchronous DNS name resolution for [Amp](http://amphp.org/amp). + +## Installation + +```bash +composer require amphp/dns +``` + +## Usage + +### Configuration + +`amphp/dns` automatically detects the system configuration and uses it. On Unix-like systems it reads `/etc/resolv.conf` and respects settings for nameservers, timeouts, and attempts. On Windows it looks up the correct entries in the Windows Registry and takes the listed nameservers. You can pass a custom `ConfigLoader` instance to `Rfc1035StubResolver` to load another configuration, such as a static config. + +It respects the system's hosts file on Unix and Windows based systems, so it works just fine in environments like Docker with named containers. + +The package uses a global default resolver with can be accessed and changed via `Amp\Dns\resolver()`. If an argument other than `null` is given, the given resolver is used as global instance. The instance is automatically bound to the current event loop. If you replace the event loop via `Amp\Loop::set()`, then you have to set a new global resolver. + +Usually you don't have to change the resolver. If you want to use a custom configuration for a certain request, you can create a new resolver instance and use that instead of changing the global one. + +### Address Resolution + +`Amp\Dns\resolve` provides hostname to IP address resolution. It returns an array of IPv4 and IPv6 addresses by default. The type of IP addresses returned can be restricted by passing a second argument with the respective type. + +```php +// Example without type restriction. Will return IPv4 and / or IPv6 addresses. +// What's returned depends on what's available for the given hostname. + +/** @var Amp\Dns\Record[] $records */ +$records = yield Amp\Dns\resolve("github.com"); +``` + +```php +// Example with type restriction. Will throw an exception if there are no A records. + +/** @var Amp\Dns\Record[] $records */ +$records = yield Amp\Dns\resolve("github.com", Amp\Dns\Record::A); +``` + +### Custom Queries + +`Amp\Dns\query` supports the various other DNS record types such as `MX`, `PTR`, or `TXT`. It automatically rewrites passed IP addresses for `PTR` lookups. + +```php +/** @var Amp\Dns\Record[] $records */ +$records = Amp\Dns\query("google.com", Amp\Dns\Record::MX); +``` + +```php +/** @var Amp\Dns\Record[] $records */ +$records = Amp\Dns\query("8.8.8.8", Amp\Dns\Record::PTR); +``` + +### Caching + +The `Rfc1035StubResolver` caches responses by default in an `Amp\Cache\ArrayCache`. You can set any other `Amp\Cache\Cache` implementation by creating a custom instance of `Rfc1035StubResolver` and setting that via `Amp\Dns\resolver()`, but it's usually unnecessary. If you have a lot of very short running scripts, you might want to consider using a local DNS resolver with a cache instead of setting a custom cache implementation, such as `dnsmasq`. + +### Reloading Configuration + +The `Rfc1035StubResolver` (which is the default resolver shipping with that package) will cache the configuration of `/etc/resolv.conf` / the Windows Registry and the read host files by default. If you wish to reload them, you can set a periodic timer that requests a background reload of the configuration. + +```php +Loop::repeat(60000, function () use ($resolver) { + yield Amp\Dns\resolver()->reloadConfig(); +}); +``` + +{:.note} +> The above code relies on the resolver not being changed. `reloadConfig` is specific to `Rfc1035StubResolver` and is not part of the `Resolver` interface. You might want to guard the reloading with an `instanceof` check or manually set a `Rfc1035StubResolver` instance on startup to be sure it's an instance of `Rfc1035StubResolver`. diff --git a/examples/_bootstrap.php b/examples/_bootstrap.php new file mode 100644 index 0000000..511d06f --- /dev/null +++ b/examples/_bootstrap.php @@ -0,0 +1,22 @@ +getType()), $record->getValue(), $record->getTtl()); + } +} + +function pretty_print_error(string $queryName, \Throwable $error) +{ + print "-- " . $queryName . " " . \str_repeat("-", 70 - \strlen($queryName)) . "\r\n"; + print \get_class($error) . ": " . $error->getMessage() . "\r\n"; +} diff --git a/examples/benchmark.php b/examples/benchmark.php new file mode 100644 index 0000000..71275a4 --- /dev/null +++ b/examples/benchmark.php @@ -0,0 +1,42 @@ +loadHosts(); + + return new Dns\Config([ + "8.8.8.8:53", + "[2001:4860:4860::8888]:53", + ], $hosts, $timeout = 5000, $attempts = 3); + }); + } +}; + +$DohConfig = new DoH\DoHConfig([new DoH\Nameserver('https://cloudflare-dns.com/dns-query', Nameserver::GOOGLE_JSON)]); +Dns\resolver(new DoH\Rfc8484StubResolver($DohConfig, null, $customConfigLoader)); + +Loop::run(function () { + $hostname = "amphp.org"; + + try { + pretty_print_records($hostname, yield Dns\resolve($hostname)); + } catch (Dns\DnsException $e) { + pretty_print_error($hostname, $e); + } +}); diff --git a/examples/ptr-record.php b/examples/ptr-record.php new file mode 100644 index 0000000..afcbfb7 --- /dev/null +++ b/examples/ptr-record.php @@ -0,0 +1,16 @@ +validateNameserver($nameserver); + } + + if ($artax === null) { + $artax = new DefaultClient(); + } + $this->artax = $artax; + $this->nameservers = $nameservers; + } + + private function validateNameserver($nameserver) + { + if (!($nameserver instanceof Nameserver)) { + throw new ConfigException("Invalid nameserver: {$nameserver}"); + } + } + + public function getNameservers(): array + { + return $this->nameservers; + } + + public function getArtax(): Client + { + return $this->artax; + } +} diff --git a/lib/Internal/HttpsSocket.php b/lib/Internal/HttpsSocket.php new file mode 100644 index 0000000..a8f7433 --- /dev/null +++ b/lib/Internal/HttpsSocket.php @@ -0,0 +1,284 @@ +httpClient = $artax; + $this->nameserver = $nameserver; + + $this->queue = new \SplQueue; + + if ($nameserver->getType() !== Nameserver::GOOGLE_JSON) { + $this->encoder = (new EncoderFactory)->create(); + $this->decoder = (new DecoderFactory)->create(); + } else { + $this->messageFactory = new MessageFactory; + $this->decodingContextFactory = new DecodingContextFactory; + $this->packetFactory = new PacketFactory; + $this->questionFactory = new QuestionFactory; + $this->resourceBuilder = (new ResourceBuilderFactory)->create(); + $this->typeBuilder = new TypeBuilder; + } + + parent::__construct(); + } + + protected function send(Message $message): Promise + { + $id = $message->getID(); + $data = $this->encoder->encode($message); + + switch ($this->nameserver->getType()) { + case Nameserver::RFC8484_GET: + $request = (new Request($this->nameserver->getUri().'?'.http_build_query(['dns' => $data]), "GET")) + ->withHeader('accept', 'application/dns-message') + ->withHeaders($this->nameserver->getHeaders()); + break; + case Nameserver::RFC8484_POST: + $request = (new Request($this->nameserver->getUri(), "POST")) + ->withBody($data) + ->withHeader('content-type', 'application/dns-message') + ->withHeader('accept', 'application/dns-message') + ->withHeaders($this->nameserver->getHeaders()); + break; + case Nameserver::GOOGLE_JSON: + $param = [ + 'cd' => 0, // Do not disable result validation + 'do' => 0, // Do not send me DNSSEC data + 'type' => $message->getType(), // Record type being requested + 'name' => $message->getQuestionRecords()->getRecordByIndex(0), + ]; + $request = (new Request($this->nameserver->getUri().'?'.http_build_query($param), "GET")) + ->withHeaders($this->nameserver->getHeaders()); + break; + } + + $deferred = new Deferred; + $promise = $this->httpClient->request($request); + $promise->onResolve(function (\Throwable $error = null, Response $result = null) use ($deferred, $id) { + if ($error) { + $deferred->fail($error); + return; + } + if ($result) { + $this->queueResponse($result, $id); + $deferred->resolve(); + } + }); + + return $deferred->promise(); + } + public function queueResponse(Response $result, int $id) + { + $this->queue->push([$result, $id]); + if ($this->responseDeferred) { + $this->responseDeferred->resolve(); + //Loop::defer([$this->responseDeferred, 'resolve']); + } + } + protected function receive(): Promise + { + return call(function () { + /** @var $result \Amp\Artax\Response */ + if ($this->queue->isEmpty()) { + if (!$this->responseDeferred) { + $this->responseDeferred = new Deferred; + } + yield $this->responseDeferred; + $this->responseDeferred = new Deferred; + + list($result, $requestId) = $this->queue->shift(); + } + + list($result, $requestId) = $this->queue->shift(); + + switch ($this->nameserver->getType()) { + case Nameserver::RFC8484_GET: + case Nameserver::RFC8484_POST: + return $this->decoder->decode(yield $result->getBody()); + case Nameserver::GOOGLE_JSON: + $result = json_decode($result, true); + + $message = $this->messageFactory->create(); + $decodingContext = $this->decodingContextFactory->create($this->packetFactory->create()); + + //$message->isAuthoritative(true); + $message->setID($requestId); + $message->setResponseCode($result['Status']); + $message->isTruncated($result['TC']); + $message->isRecursionDesired($result['RD']); + $message->isRecursionAvailable($result['RA']); + + $decodingContext->setExpectedQuestionRecords(isset($result['Question']) ? count($result['Question']) : 0); + $decodingContext->setExpectedAnswerRecords(isset($result['Answer']) ? count($result['Answer']) : 0); + $decodingContext->setExpectedAuthorityRecords(0); + $decodingContext->setExpectedAdditionalRecords(isset($result['Additional']) ? count($result['Additional']) : 0); + + $questionRecords = $message->getQuestionRecords(); + $expected = $decodingContext->getExpectedQuestionRecords(); + for ($i = 0; $i < $expected; $i++) { + $questionRecords->add($this->decodeQuestionRecord($decodingContext, $result['Question'][$i])); + } + + $answerRecords = $message->getAnswerRecords(); + $expected = $decodingContext->getExpectedAnswerRecords(); + for ($i = 0; $i < $expected; $i++) { + $answerRecords->add($this->decodeResourceRecord($decodingContext, $result['Answer'][$i])); + } + + $authorityRecords = $message->getAuthorityRecords(); + $expected = $decodingContext->getExpectedAuthorityRecords(); + for ($i = 0; $i < $expected; $i++) { + $authorityRecords->add($this->decodeResourceRecord($decodingContext, $result['Authority'][$i])); + } + + $additionalRecords = $message->getAdditionalRecords(); + $expected = $decodingContext->getExpectedAdditionalRecords(); + for ($i = 0; $i < $expected; $i++) { + $additionalRecords->add($this->decodeResourceRecord($decodingContext, $result['Additional'][$i])); + } + + return $message; + } + }); + } + + + /** + * Decode a question record + * + * @param \LibDNS\Decoder\DecodingContext $decodingContext + * @return \LibDNS\Records\Question + * @throws \UnexpectedValueException When the record is invalid + */ + private function decodeQuestionRecord(DecodingContext $decodingContext, array $record): Question + { + /** @var \LibDNS\Records\Types\DomainName $domainName */ + $domainName = $this->typeBuilder->build(Types::DOMAIN_NAME); + $domainName->setLabels(explode('.', $record['name'])); + + $question = $this->questionFactory->create($record['type']); + $question->setName($domainName); + //$question->setClass($meta['class']); + + return $question; + } + + /** + * Decode a resource record + * + * @param \LibDNS\Decoder\DecodingContext $decodingContext + * @return \LibDNS\Records\Resource + * @throws \UnexpectedValueException When the record is invalid + * @throws \InvalidArgumentException When a type subtype is unknown + */ + private function decodeResourceRecord(DecodingContext $decodingContext, array $record): Resource + { + /** @var \LibDNS\Records\Types\DomainName $domainName */ + $domainName = $this->typeBuilder->build(Types::DOMAIN_NAME); + $domainName->setLabels(explode('.', $record['name'])); + + $resource = $this->resourceBuilder->build($record['type']); + $resource->setName($domainName); + //$resource->setClass($meta['class']); + $resource->setTTL($record['ttl']); + + $data = $resource->getData(); + + $fieldDef = $index = null; + foreach ($resource->getData()->getTypeDefinition() as $index => $fieldDef) { + $field = $this->typeBuilder->build($fieldDef->getType()); + $remainingLength -= $this->decodeType($decodingContext, $field, $remainingLength); + $data->setField($index, $field); + } + + if ($fieldDef->allowsMultiple()) { + while ($remainingLength) { + $field = $this->typeBuilder->build($fieldDef->getType()); + $remainingLength -= $this->decodeType($decodingContext, $field, $remainingLength); + $data->setField(++$index, $field); + } + } + + if ($remainingLength !== 0) { + throw new \UnexpectedValueException('Decode error: Invalid length for record data section'); + } + + return $resource; + } +} diff --git a/lib/Internal/Socket.php b/lib/Internal/Socket.php new file mode 100644 index 0000000..529dd08 --- /dev/null +++ b/lib/Internal/Socket.php @@ -0,0 +1,233 @@ + + */ + + abstract public static function connect(Client $artax, Nameserver $nameserver): Socket; + + /** + * @param Message $message + * + * @return Promise + */ + abstract protected function send(Message $message): Promise; + + /** + * @return Promise + */ + abstract protected function receive(): Promise; + + /** + * @return void + */ + abstract public function close(); + + protected function __construct() + { + $this->messageFactory = new MessageFactory; + + $this->onResolve = function (\Throwable $exception = null, Message $message = null) { + $this->receiving = false; + + if ($exception) { + $this->error($exception); + return; + } + + \assert($message instanceof Message); + $id = $message->getId(); + + // Ignore duplicate and invalid responses. + if (isset($this->pending[$id]) && $this->matchesQuestion($message, $this->pending[$id]->question)) { + /** @var Deferred $deferred */ + $deferred = $this->pending[$id]->deferred; + unset($this->pending[$id]); + $deferred->resolve($message); + } + + if (empty($this->pending)) { + $this->input->unreference(); + } elseif (!$this->receiving) { + $this->input->reference(); + $this->receiving = true; + $this->receive()->onResolve($this->onResolve); + } + }; + } + + /** + * @param \LibDNS\Records\Question $question + * @param int $timeout + * + * @return \Amp\Promise<\LibDNS\Messages\Message> + */ + final public function ask(Question $question, int $timeout): Promise + { + return call(function () use ($question, $timeout) { + if (\count($this->pending) > self::MAX_CONCURRENT_REQUESTS) { + $deferred = new Deferred; + $this->queue[] = $deferred; + yield $deferred->promise(); + } + + do { + $id = \random_int(0, 0xffff); + } while (isset($this->pending[$id])); + + $deferred = new Deferred; + $pending = new class { + use Amp\Struct; + + public $deferred; + public $question; + }; + + $pending->deferred = $deferred; + $pending->question = $question; + $this->pending[$id] = $pending; + + $message = $this->createMessage($question, $id); + + try { + yield $this->send($message); + } catch (StreamException $exception) { + $exception = new DnsException("Sending the request failed", 0, $exception); + $this->error($exception); + throw $exception; + } + + $this->input->reference(); + + if (!$this->receiving) { + $this->receiving = true; + $this->receive()->onResolve($this->onResolve); + } + + try { + // Work around an OPCache issue that returns an empty array with "return yield ...", + // so assign to a variable first and return after the try block. + // + // See https://github.com/amphp/dns/issues/58. + // See https://bugs.php.net/bug.php?id=74840. + $result = yield Promise\timeout($deferred->promise(), $timeout); + } catch (Amp\TimeoutException $exception) { + unset($this->pending[$id]); + + if (empty($this->pending)) { + $this->input->unreference(); + } + + throw new TimeoutException("Didn't receive a response within {$timeout} milliseconds."); + } finally { + if ($this->queue) { + $deferred = \array_shift($this->queue); + $deferred->resolve(); + } + } + + return $result; + }); + } + + private function error(\Throwable $exception) + { + $this->close(); + + if (empty($this->pending)) { + return; + } + + if (!$exception instanceof DnsException) { + $message = "Unexpected error during resolution: " . $exception->getMessage(); + $exception = new DnsException($message, 0, $exception); + } + + $pending = $this->pending; + $this->pending = []; + + foreach ($pending as $pendingQuestion) { + /** @var Deferred $deferred */ + $deferred = $pendingQuestion->deferred; + $deferred->fail($exception); + } + } + + + final protected function createMessage(Question $question, int $id): Message + { + $request = $this->messageFactory->create(MessageTypes::QUERY); + $request->getQuestionRecords()->add($question); + $request->isRecursionDesired(true); + $request->setID($id); + return $request; + } + + private function matchesQuestion(Message $message, Question $question): bool + { + if ($message->getType() !== MessageTypes::RESPONSE) { + return false; + } + + $questionRecords = $message->getQuestionRecords(); + + // We only ever ask one question at a time + if (\count($questionRecords) !== 1) { + return false; + } + + $questionRecord = $questionRecords->getIterator()->current(); + + if ($questionRecord->getClass() !== $question->getClass()) { + return false; + } + + if ($questionRecord->getType() !== $question->getType()) { + return false; + } + + if ($questionRecord->getName()->getValue() !== $question->getName()->getValue()) { + return false; + } + + return true; + } +} diff --git a/lib/Nameserver.php b/lib/Nameserver.php new file mode 100644 index 0000000..99683e9 --- /dev/null +++ b/lib/Nameserver.php @@ -0,0 +1,44 @@ +uri = $uri; + $this->type = $type; + $this->headers = $headers; + } + public function getUri(): string + { + return $this->uri; + } + public function getHeaders(): array + { + return $this->headers; + } + public function getType(): int + { + return $this->type; + } + public function __toString(): string + { + switch ($this->type) { + case self::RFC8484_GET: + return "{$this->uri} RFC 8484 GET"; + case self::RFC8484_POST: + return "{$this->uri} RFC 8484 POST"; + case self::GOOGLE_JSON: + return "{$this->uri} google JSON"; + } + } +} diff --git a/lib/Rfc8484StubResolver.php b/lib/Rfc8484StubResolver.php new file mode 100644 index 0000000..57ac6e6 --- /dev/null +++ b/lib/Rfc8484StubResolver.php @@ -0,0 +1,379 @@ +cache = $cache ?? new ArrayCache(5000/* default gc interval */, 256/* size */); + $this->configLoader = $configLoader ?? (\stripos(PHP_OS, "win") === 0 + ? new WindowsConfigLoader() + : new UnixConfigLoader); + $this->dohConfig = $config; + + $this->questionFactory = new QuestionFactory; + } + + /** @inheritdoc */ + public function resolve(string $name, int $typeRestriction = null): Promise + { + if ($typeRestriction !== null && $typeRestriction !== Record::A && $typeRestriction !== Record::AAAA) { + throw new \Error("Invalid value for parameter 2: null|Record::A|Record::AAAA expected"); + } + + return call(function () use ($name, $typeRestriction) { + if (!$this->config) { + yield $this->reloadConfig(); + } + + switch ($typeRestriction) { + case Record::A: + if (\filter_var($name, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return [new Record($name, Record::A, null)]; + } elseif (\filter_var($name, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + throw new DnsException("Got an IPv6 address, but type is restricted to IPv4"); + } + break; + case Record::AAAA: + if (\filter_var($name, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return [new Record($name, Record::AAAA, null)]; + } elseif (\filter_var($name, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + throw new DnsException("Got an IPv4 address, but type is restricted to IPv6"); + } + break; + default: + if (\filter_var($name, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return [new Record($name, Record::A, null)]; + } elseif (\filter_var($name, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return [new Record($name, Record::AAAA, null)]; + } + break; + } + + $name = normalizeName($name); + + if ($records = $this->queryHosts($name, $typeRestriction)) { + return $records; + } + + // Follow RFC 6761 and never send queries for localhost to the caching DNS server + // Usually, these queries are already resolved via queryHosts() + if ($name === 'localhost') { + return $typeRestriction === Record::AAAA + ? [new Record('::1', Record::AAAA, null)] + : [new Record('127.0.0.1', Record::A, null)]; + } + + for ($redirects = 0; $redirects < 5; $redirects++) { + try { + if ($typeRestriction) { + $records = yield $this->query($name, $typeRestriction); + } else { + try { + list(, $records) = yield Promise\some([ + $this->query($name, Record::A), + $this->query($name, Record::AAAA), + ]); + + $records = \array_merge(...$records); + + break; // Break redirect loop, otherwise we query the same records 5 times + } catch (MultiReasonException $e) { + $errors = []; + + foreach ($e->getReasons() as $reason) { + if ($reason instanceof NoRecordException) { + throw $reason; + } + + $errors[] = $reason->getMessage(); + } + + throw new DnsException("All query attempts failed for {$name}: ".\implode(", ", $errors), 0, $e); + } + } + } catch (NoRecordException $e) { + try { + /** @var Record[] $cnameRecords */ + $cnameRecords = yield $this->query($name, Record::CNAME); + $name = $cnameRecords[0]->getValue(); + continue; + } catch (NoRecordException $e) { + /** @var Record[] $dnameRecords */ + $dnameRecords = yield $this->query($name, Record::DNAME); + $name = $dnameRecords[0]->getValue(); + continue; + } + } + } + + return $records; + }); + } + + /** + * Reloads the configuration in the background. + * + * Once it's finished, the configuration will be used for new requests. + * + * @return Promise + */ + public function reloadConfig(): Promise + { + if ($this->pendingConfig) { + return $this->pendingConfig; + } + + $promise = call(function () { + $this->config = yield $this->configLoader->loadConfig(); + }); + + $this->pendingConfig = $promise; + + $promise->onResolve(function () { + $this->pendingConfig = null; + }); + + return $promise; + } + + private function queryHosts(string $name, int $typeRestriction = null): array + { + $hosts = $this->config->getKnownHosts(); + $records = []; + + $returnIPv4 = $typeRestriction === null || $typeRestriction === Record::A; + $returnIPv6 = $typeRestriction === null || $typeRestriction === Record::AAAA; + + if ($returnIPv4 && isset($hosts[Record::A][$name])) { + $records[] = new Record($hosts[Record::A][$name], Record::A, null); + } + + if ($returnIPv6 && isset($hosts[Record::AAAA][$name])) { + $records[] = new Record($hosts[Record::AAAA][$name], Record::AAAA, null); + } + + return $records; + } + + /** @inheritdoc */ + public function query(string $name, int $type): Promise + { + $pendingQueryKey = $type." ".$name; + + if (isset($this->pendingQueries[$pendingQueryKey])) { + return $this->pendingQueries[$pendingQueryKey]; + } + + $promise = call(function () use ($name, $type) { + if (!$this->config) { + yield $this->reloadConfig(); + } + + $name = $this->normalizeName($name, $type); + $question = $this->createQuestion($name, $type); + + if (null !== $cachedValue = yield $this->cache->get($this->getCacheKey($name, $type))) { + return $this->decodeCachedResult($name, $type, $cachedValue); + } + + /** @var Nameserver[] $nameservers */ + $nameservers = $this->dohConfig->getNameservers(); + $attempts = $this->config->getAttempts(); + $attempt = 0; + + /** @var Socket $socket */ + $nameserver = $nameservers[0]; + $socket = $this->getSocket($nameserver); + + $attemptDescription = []; + + while ($attempt < $attempts) { + try { + $attemptDescription[] = $nameserver; + + /** @var Message $response */ + $response = yield $socket->ask($question, $this->config->getTimeout()); + $this->assertAcceptableResponse($response); + + if ($response->isTruncated()) { + throw new DnsException("Server returned a truncated response for '{$name}' (".Record::getName($type).")"); + } + + $answers = $response->getAnswerRecords(); + $result = []; + $ttls = []; + + /** @var \LibDNS\Records\Resource $record */ + foreach ($answers as $record) { + $recordType = $record->getType(); + $result[$recordType][] = (string) $record->getData(); + + // Cache for max one day + $ttls[$recordType] = \min($ttls[$recordType] ?? 86400, $record->getTTL()); + } + + foreach ($result as $recordType => $records) { + // We don't care here whether storing in the cache fails + $this->cache->set($this->getCacheKey($name, $recordType), \json_encode($records), $ttls[$recordType]); + } + + if (!isset($result[$type])) { + // "it MUST NOT cache it for longer than five (5) minutes" per RFC 2308 section 7.1 + $this->cache->set($this->getCacheKey($name, $type), \json_encode([]), 300); + throw new NoRecordException("No records returned for '{$name}' (".Record::getName($type).")"); + } + + return \array_map(function ($data) use ($type, $ttls) { + return new Record($data, $type, $ttls[$type]); + }, $result[$type]); + } catch (TimeoutException $e) { + // Defer call, because it might interfere with the unreference() call in Internal\Socket otherwise + + $i = ++$attempt % \count($nameservers); + $nameserver = $nameservers[$i]; + $socket = yield $this->getSocket($nameserver); + continue; + } + } + + throw new TimeoutException(\sprintf( + "No response for '%s' (%s) from any nameserver after %d attempts, tried %s", + $name, + Record::getName($type), + $attempts, + \implode(", ", $attemptDescription) + )); + }); + + $this->pendingQueries[$type." ".$name] = $promise; + $promise->onResolve(function () use ($name, $type) { + unset($this->pendingQueries[$type." ".$name]); + }); + + return $promise; + } + + private function normalizeName(string $name, int $type) + { + if ($type === Record::PTR) { + if (($packedIp = @\inet_pton($name)) !== false) { + if (isset($packedIp[4])) { // IPv6 + $name = \wordwrap(\strrev(\bin2hex($packedIp)), 1, ".", true).".ip6.arpa"; + } else { // IPv4 + $name = \inet_ntop(\strrev($packedIp)).".in-addr.arpa"; + } + } + } elseif (\in_array($type, [Record::A, Record::AAAA])) { + $name = normalizeName($name); + } + + return $name; + } + + /** + * @param string $name + * @param int $type + * + * @return \LibDNS\Records\Question + */ + private function createQuestion(string $name, int $type): Question + { + if (0 > $type || 0xffff < $type) { + $message = \sprintf('%d does not correspond to a valid record type (must be between 0 and 65535).', $type); + throw new \Error($message); + } + + $question = $this->questionFactory->create($type); + $question->setName($name); + + return $question; + } + + private function getCacheKey(string $name, int $type): string + { + return self::CACHE_PREFIX.$name."#".$type; + } + + private function decodeCachedResult(string $name, int $type, string $encoded) + { + $decoded = \json_decode($encoded, true); + + if (!$decoded) { + throw new NoRecordException("No records returned for {$name} (cached result)"); + } + + $result = []; + + foreach ($decoded as $data) { + $result[] = new Record($data, $type); + } + + return $result; + } + + private function getSocket(Nameserver $nameserver) + { + $uri = $nameserver->getUri(); + if (isset($this->sockets[$uri])) { + return new Success($this->sockets[$uri]); + } + + + $this->sockets[$uri] = HttpsSocket::connect($this->dohConfig->getArtax(), $nameserver); + + return $this->sockets[$uri]; + } + + private function assertAcceptableResponse(Message $response) + { + if ($response->getResponseCode() !== 0) { + throw new DnsException(\sprintf("Server returned error code: %d", $response->getResponseCode())); + } + } +} diff --git a/lib/functions.php b/lib/functions.php new file mode 100644 index 0000000..7077e5f --- /dev/null +++ b/lib/functions.php @@ -0,0 +1,109 @@ +resolve($name, $typeRestriction); +} + +/** + * @see Resolver::query() + */ +function query(string $name, int $type): Promise +{ + return resolver()->query($name, $type); +} + +/** + * Checks whether a string is a valid DNS name. + * + * @param string $name String to check. + * + * @return bool + */ +function isValidName(string $name) +{ + try { + normalizeName($name); + return true; + } catch (InvalidNameException $e) { + return false; + } +} + +/** + * Normalizes a DNS name and automatically checks it for validity. + * + * @param string $name DNS name. + * + * @return string Normalized DNS name. + * @throws InvalidNameException If an invalid name or an IDN name without ext/intl being installed has been passed. + */ +function normalizeName(string $name): string +{ + static $pattern = '/^(?[a-z0-9]([a-z0-9-_]{0,61}[a-z0-9])?)(\.(?&name))*$/i'; + + if (\function_exists('idn_to_ascii') && \defined('INTL_IDNA_VARIANT_UTS46')) { + if (false === $result = \idn_to_ascii($name, 0, \INTL_IDNA_VARIANT_UTS46)) { + throw new InvalidNameException("Name '{$name}' could not be processed for IDN."); + } + + $name = $result; + } elseif (\preg_match('/[\x80-\xff]/', $name)) { + throw new InvalidNameException( + "Name '{$name}' contains non-ASCII characters and IDN support is not available. " . + "Verify that ext/intl is installed for IDN support and that ICU is at least version 4.6." + ); + } + + if (isset($name[253]) || !\preg_match($pattern, $name)) { + throw new InvalidNameException("Name '{$name}' is not a valid hostname."); + } + + return $name; +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..ce62c46 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + test + + + + + lib + + + + + + diff --git a/test/ConfigTest.php b/test/ConfigTest.php new file mode 100644 index 0000000..772ab34 --- /dev/null +++ b/test/ConfigTest.php @@ -0,0 +1,77 @@ +assertInstanceOf(Config::class, new Config($nameservers)); + } + + public function provideValidServers() + { + return [ + [["127.1.1.1"]], + [["127.1.1.1:1"]], + [["[::1]:52"]], + [["[::1]"]], + ]; + } + + /** + * @param string[] $nameservers Invalid server array. + * + * @dataProvider provideInvalidServers + */ + public function testRejectsInvalidServers(array $nameservers) + { + $this->expectException(ConfigException::class); + new Config($nameservers); + } + + public function provideInvalidServers() + { + return [ + [[]], + [[42]], + [[null]], + [[true]], + [["foobar"]], + [["foobar.com"]], + [["127.1.1"]], + [["127.1.1.1.1"]], + [["126.0.0.5", "foobar"]], + [["42"]], + [["::1"]], + [["::1:53"]], + [["[::1]:"]], + [["[::1]:76235"]], + [["[::1]:0"]], + [["[::1]:-1"]], + [["[::1:51"]], + [["[::1]:abc"]], + ]; + } + + public function testInvalidTimeout() + { + $this->expectException(ConfigException::class); + new Config(["127.0.0.1"], [], -1); + } + + public function testInvalidAttempts() + { + $this->expectException(ConfigException::class); + new Config(["127.0.0.1"], [], 500, 0); + } +} diff --git a/test/DecodeTest.php b/test/DecodeTest.php new file mode 100644 index 0000000..c5b4369 --- /dev/null +++ b/test/DecodeTest.php @@ -0,0 +1,23 @@ +create(); + $response = $decoder->decode($message); + + $this->assertInstanceOf(Message::class, $response); + } +} diff --git a/test/HostLoaderTest.php b/test/HostLoaderTest.php new file mode 100644 index 0000000..f273f4a --- /dev/null +++ b/test/HostLoaderTest.php @@ -0,0 +1,43 @@ +assertSame([ + Record::A => [ + "localhost" => "127.0.0.1", + ], + ], yield $loader->loadHosts()); + }); + } + + public function testReturnsEmptyErrorOnFileNotFound() + { + Loop::run(function () { + $loader = new HostLoader(__DIR__ . "/data/hosts.not.found"); + $this->assertSame([], yield $loader->loadHosts()); + }); + } + + public function testIgnoresInvalidNames() + { + Loop::run(function () { + $loader = new HostLoader(__DIR__ . "/data/hosts.invalid.name"); + $this->assertSame([ + Record::A => [ + "localhost" => "127.0.0.1", + ], + ], yield $loader->loadHosts()); + }); + } +} diff --git a/test/IntegrationTest.php b/test/IntegrationTest.php new file mode 100644 index 0000000..f8aa1e3 --- /dev/null +++ b/test/IntegrationTest.php @@ -0,0 +1,126 @@ +getValue()); + $this->assertNotFalse( + $inAddr, + "Server name $hostname did not resolve to a valid IP address" + ); + }); + } + + /** + * @group internet + */ + public function testWorksAfterConfigReload() + { + Loop::run(function () { + yield Dns\query("google.com", Record::A); + $this->assertNull(yield Dns\resolver()->reloadConfig()); + $this->assertInternalType("array", yield Dns\query("example.com", Record::A)); + }); + } + + public function testResolveIPv4only() + { + Loop::run(function () { + $records = yield Dns\resolve("google.com", Record::A); + + /** @var Record $record */ + foreach ($records as $record) { + $this->assertSame(Record::A, $record->getType()); + $inAddr = @\inet_pton($record->getValue()); + $this->assertNotFalse( + $inAddr, + "Server name google.com did not resolve to a valid IP address" + ); + } + }); + } + + public function testResolveIPv6only() + { + Loop::run(function () { + $records = yield Dns\resolve("google.com", Record::AAAA); + + /** @var Record $record */ + foreach ($records as $record) { + $this->assertSame(Record::AAAA, $record->getType()); + $inAddr = @\inet_pton($record->getValue()); + $this->assertNotFalse( + $inAddr, + "Server name google.com did not resolve to a valid IP address" + ); + } + }); + } + + public function testPtrLookup() + { + Loop::run(function () { + $result = yield Dns\query("8.8.4.4", Record::PTR); + + /** @var Record $record */ + $record = $result[0]; + $this->assertSame("google-public-dns-b.google.com", $record->getValue()); + $this->assertNotNull($record->getTtl()); + $this->assertSame(Record::PTR, $record->getType()); + }); + } + + /** + * Test that two concurrent requests to the same resource share the same request and do not result in two requests + * being sent. + */ + public function testRequestSharing() + { + Loop::run(function () { + $promise1 = Dns\query("example.com", Record::A); + $promise2 = Dns\query("example.com", Record::A); + + $this->assertSame($promise1, $promise2); + $this->assertSame(yield $promise1, yield $promise2); + }); + } + + public function provideHostnames() + { + return [ + ["google.com"], + ["github.com"], + ["stackoverflow.com"], + ["blog.kelunik.com"], /* that's a CNAME to GH pages */ + ["localhost"], + ["192.168.0.1"], + ["::1"], + ]; + } + + public function provideServers() + { + return [ + ["8.8.8.8"], + ["8.8.8.8:53"], + ]; + } +} diff --git a/test/RecordTest.php b/test/RecordTest.php new file mode 100644 index 0000000..69f8b1f --- /dev/null +++ b/test/RecordTest.php @@ -0,0 +1,27 @@ +assertSame("A", Record::getName(Record::A)); + } + + public function testGetNameOnInvalidRecordType() + { + $this->expectException(\Error::class); + $this->expectExceptionMessage("65536 does not correspond to a valid record type (must be between 0 and 65535)."); + + Record::getName(65536); + } + + public function testGetNameOnUnknownRecordType() + { + $this->assertSame("unknown (1000)", Record::getName(1000)); + } +} diff --git a/test/Rfc1035StubResolverTest.php b/test/Rfc1035StubResolverTest.php new file mode 100644 index 0000000..eaea00c --- /dev/null +++ b/test/Rfc1035StubResolverTest.php @@ -0,0 +1,45 @@ +expectException(\Error::class); + (new Rfc1035StubResolver)->resolve("abc.de", Record::TXT); + }); + } + + public function testIpAsArgumentWithIPv4Restriction() + { + Loop::run(function () { + $this->expectException(DnsException::class); + yield (new Rfc1035StubResolver)->resolve("::1", Record::A); + }); + } + + public function testIpAsArgumentWithIPv6Restriction() + { + Loop::run(function () { + $this->expectException(DnsException::class); + yield (new Rfc1035StubResolver)->resolve("127.0.0.1", Record::AAAA); + }); + } + + public function testInvalidName() + { + Loop::run(function () { + $this->expectException(InvalidNameException::class); + yield (new Rfc1035StubResolver)->resolve("go@gle.com", Record::A); + }); + } +} diff --git a/test/SocketTest.php b/test/SocketTest.php new file mode 100644 index 0000000..dbea77b --- /dev/null +++ b/test/SocketTest.php @@ -0,0 +1,46 @@ +create(Dns\Record::A); + $question->setName("google.com"); + + /** @var Dns\Internal\Socket $socket */ + $socket = yield $this->connect(); + + /** @var Message $result */ + $result = yield $socket->ask($question, 5000); + + $this->assertInstanceOf(Message::class, $result); + $this->assertSame(MessageTypes::RESPONSE, $result->getType()); + }); + } + + public function testGetLastActivity() + { + Loop::run(function () { + $question = (new QuestionFactory)->create(Dns\Record::A); + $question->setName("google.com"); + + /** @var Dns\Internal\Socket $socket */ + $socket = yield $this->connect(); + + $this->assertLessThan(\time() + 1, $socket->getLastActivity()); + }); + } +} diff --git a/test/TcpSocketTest.php b/test/TcpSocketTest.php new file mode 100644 index 0000000..d3b79e6 --- /dev/null +++ b/test/TcpSocketTest.php @@ -0,0 +1,57 @@ +expectException(Dns\TimeoutException::class); + wait(Dns\Internal\TcpSocket::connect("tcp://8.8.8.8:53", 0)); + } + + public function testInvalidUri() + { + $this->expectException(Dns\DnsException::class); + wait(Dns\Internal\TcpSocket::connect("tcp://8.8.8.8")); + } + + public function testAfterConnectionTimedOut() + { + Loop::run(function () { + $question = (new QuestionFactory)->create(Dns\Record::A); + $question->setName("google.com"); + + /** @var Dns\Internal\Socket $socket */ + $socket = yield $this->connect(); + + /** @var Message $result */ + $result = yield $socket->ask($question, 3000); + + $this->assertInstanceOf(Message::class, $result); + $this->assertSame(MessageTypes::RESPONSE, $result->getType()); + + // Google's DNS times out really fast + yield new Delayed(3000); + + $this->expectException(Dns\DnsException::class); + $this->expectExceptionMessageRegExp("(Sending the request failed|Reading from the server failed)"); + + yield $socket->ask($question, 3000); + }); + } +} diff --git a/test/UdpSocketTest.php b/test/UdpSocketTest.php new file mode 100644 index 0000000..fad342d --- /dev/null +++ b/test/UdpSocketTest.php @@ -0,0 +1,21 @@ +expectException(Dns\DnsException::class); + wait(Dns\Internal\UdpSocket::connect("udp://8.8.8.8")); + } +} diff --git a/test/UnixConfigLoaderTest.php b/test/UnixConfigLoaderTest.php new file mode 100644 index 0000000..6bdeaac --- /dev/null +++ b/test/UnixConfigLoaderTest.php @@ -0,0 +1,34 @@ +loadConfig()); + + $this->assertSame([ + "127.0.0.1:53", + "[2001:4860:4860::8888]:53", + ], $result->getNameservers()); + + $this->assertSame(5000, $result->getTimeout()); + $this->assertSame(3, $result->getAttempts()); + } + + public function testNoDefaultsOnConfNotFound() + { + $this->expectException(ConfigException::class); + wait((new UnixConfigLoader(__DIR__ . "/data/non-existent.conf"))->loadConfig()); + } +} diff --git a/test/data/hosts b/test/data/hosts new file mode 100644 index 0000000..0435bb9 --- /dev/null +++ b/test/data/hosts @@ -0,0 +1,2 @@ +# localhost +127.0.0.1 localhost diff --git a/test/data/hosts.invalid.name b/test/data/hosts.invalid.name new file mode 100644 index 0000000..6cc2d3c --- /dev/null +++ b/test/data/hosts.invalid.name @@ -0,0 +1,2 @@ +# aaa...aa is too long +127.0.0.1 localhost aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/test/data/resolv.conf b/test/data/resolv.conf new file mode 100644 index 0000000..0f8dcf5 --- /dev/null +++ b/test/data/resolv.conf @@ -0,0 +1,14 @@ +# Default +nameserver 127.0.0.1 + +# Google Fallback +nameserver 2001:4860:4860::8888 + +# Invalid server gets ignored +nameserver foobar + +options timeout 5000 +options attempts 3 + +# Unknown option gets ignored +options foo \ No newline at end of file