First commit

This commit is contained in:
Daniil Gentili 2019-06-10 19:32:28 +02:00
commit 6f0b89aa75
40 changed files with 2197 additions and 0 deletions

10
.gitattributes vendored Normal file
View File

@ -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

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
build
composer.lock
phpunit.xml
vendor
.php_cs.cache
coverage

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "docs/.shared"]
path = docs/.shared
url = https://github.com/amphp/amphp.github.io

13
.php_cs.dist Normal file
View File

@ -0,0 +1,13 @@
<?php
$config = new Amp\CodeStyle\Config();
$config->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;

39
.travis.yml Normal file
View File

@ -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

30
CONTRIBUTING.md Normal file
View File

@ -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

21
LICENSE Normal file
View File

@ -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.

45
Makefile Normal file
View File

@ -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 $@

41
README.md Normal file
View File

@ -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
<?php
require __DIR__ . '/examples/_bootstrap.php';
use Amp\Dns;
use Amp\Loop;
Loop::run(function () {
$githubIpv4 = yield Dns\resolve("github.com", Dns\Record::A);
pretty_print_records("github.com", $githubIpv4);
$googleIpv4 = Amp\Dns\resolve("google.com", Dns\Record::A);
$googleIpv6 = Amp\Dns\resolve("google.com", Dns\Record::AAAA);
$firstGoogleResult = yield Amp\Promise\first([$googleIpv4, $googleIpv6]);
pretty_print_records("google.com", $firstGoogleResult);
$combinedGoogleResult = yield Amp\Dns\resolve("google.com");
pretty_print_records("google.com", $combinedGoogleResult);
$googleMx = yield Amp\Dns\query("google.com", Amp\Dns\Record::MX);
pretty_print_records("google.com", $googleMx);
});
```

38
appveyor.yml Normal file
View File

@ -0,0 +1,38 @@
build: false
shallow_clone: false
platform:
- x86
- x64
clone_folder: c:\projects\amphp
cache:
- c:\tools\php73 -> 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

87
composer.json Normal file
View File

@ -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"
}
]
}

3
docs/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.bundle
_site
Gemfile.lock

5
docs/Gemfile Normal file
View File

@ -0,0 +1,5 @@
source "https://rubygems.org"
gem "github-pages"
gem "kramdown"
gem "jekyll-github-metadata"
gem "jekyll-relative-links"

26
docs/_config.yml Normal file
View File

@ -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"

1
docs/asset Symbolic link
View File

@ -0,0 +1 @@
.shared/asset

73
docs/index.md Normal file
View File

@ -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`.

22
examples/_bootstrap.php Normal file
View File

@ -0,0 +1,22 @@
<?php
use Amp\Dns\Record;
require __DIR__ . "/../vendor/autoload.php";
function pretty_print_records(string $queryName, array $records)
{
print "---------- " . $queryName . " " . \str_repeat("-", 55 - \strlen($queryName)) . " TTL --\r\n";
$format = "%-10s %-56s %-5d\r\n";
foreach ($records as $record) {
print \sprintf($format, Record::getName($record->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";
}

42
examples/benchmark.php Normal file
View File

@ -0,0 +1,42 @@
<?php
require __DIR__ . "/_bootstrap.php";
use Amp\Dns;
use Amp\Loop;
print "Downloading top 500 domains..." . PHP_EOL;
$domains = \file_get_contents("https://moz.com/top500/domains/csv");
$domains = \array_map(function ($line) {
return \trim(\explode(",", $line)[1], '"/');
}, \array_filter(\explode("\n", $domains)));
// Remove "URL" header
\array_shift($domains);
Loop::run(function () use ($domains) {
print "Starting sequential queries...\r\n\r\n";
$timings = [];
for ($i = 0; $i < 10; $i++) {
$start = \microtime(1);
$domain = $domains[\random_int(0, \count($domains) - 1)];
try {
pretty_print_records($domain, yield Dns\resolve($domain));
} catch (Dns\DnsException $e) {
pretty_print_error($domain, $e);
}
$time = \round(\microtime(1) - $start, 2);
$timings[] = $time;
\printf("%'-74s\r\n\r\n", " in " . $time . " ms");
}
$averageTime = \array_sum($timings) / \count($timings);
print "{$averageTime} ms for an average query." . PHP_EOL;
});

View File

@ -0,0 +1,36 @@
<?php
require __DIR__ . "/_bootstrap.php";
use Amp\Dns;
use Amp\DoH;
use Amp\Loop;
use Amp\Promise;
use Amp\DoH\Nameserver;
$customConfigLoader = new class implements Dns\ConfigLoader {
public function loadConfig(): Promise
{
return Amp\call(function () {
$hosts = yield (new Dns\HostLoader)->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);
}
});

16
examples/ptr-record.php Normal file
View File

@ -0,0 +1,16 @@
<?php
require __DIR__ . "/_bootstrap.php";
use Amp\Dns;
use Amp\Loop;
Loop::run(function () {
$ip = "8.8.8.8";
try {
pretty_print_records($ip, yield Dns\query($ip, Dns\Record::PTR));
} catch (Dns\DnsException $e) {
pretty_print_error($ip, $e);
}
});

46
lib/DoHConfig.php Normal file
View File

@ -0,0 +1,46 @@
<?php
namespace Amp\DoH;
use Amp\Artax\Client;
use Amp\Artax\DefaultClient;
final class DoHConfig
{
private $nameservers;
private $artax;
public function __construct(array $nameservers, Client $artax = null)
{
if (\count($nameservers) < 1) {
throw new ConfigException("At least one nameserver is required for a valid config");
}
foreach ($nameservers as $nameserver) {
$this->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;
}
}

View File

@ -0,0 +1,284 @@
<?php
namespace Amp\DoH\Internal;
use Amp;
use Amp\Artax\Client;
use Amp\Artax\Request;
use Amp\Artax\Response;
use Amp\Deferred;
use Amp\DoH\Nameserver;
use Amp\Promise;
use function Amp\call;
use LibDNS\Decoder\DecoderFactory;
use LibDNS\Decoder\DecodingContextFactory;
use LibDNS\Encoder\EncoderFactory;
use LibDNS\Messages\Message;
use LibDNS\Messages\MessageFactory;
use LibDNS\Packets\PacketFactory;
use LibDNS\Records\Types\Types;
use LibDNS\Decoder\DecodingContext;
use LibDNS\Records\QuestionFactory;
use LibDNS\Records\ResourceBuilderFactory;
use LibDNS\Records\Types\TypeBuilder;
use LibDNS\Records\Question;
use LibDNS\Records\Resource;
/** @internal */
final class HttpsSocket extends Socket
{
/** @var \Amp\Artax\Client */
private $httpClient;
/** @var \Amp\DoH\Nameserver */
private $nameserver;
/** @var \LibDNS\Encoder\Encoder */
private $encoder;
/** @var \LibDNS\Decoder\Decoder */
private $decoder;
/** @var \LibDNS\Messages\MessageFactory */
private $messageFactory;
/** @var \LibDNS\Decoder\DecodingContextFactory */
private $decodingContextFactory;
/**
* @var \LibDNS\Packets\PacketFactory
*/
private $packetFactory;
/**
* @var \LibDNS\Records\QuestionFactory
*/
private $questionFactory;
/**
* @var \LibDNS\Records\ResourceBuilder
*/
private $resourceBuilder;
/**
* @var \LibDNS\Records\Types\TypeBuilder
*/
private $typeBuilder;
/** @var \Amp\Artax\Response[] */
private $queue;
/** @var \Amp\Deferred */
private $responseDeferred;
public static function connect(Client $artax, Nameserver $nameserver): self
{
return new self($artax, $nameserver);
}
protected function __construct(Client $artax, Nameserver $nameserver)
{
$this->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;
}
}

233
lib/Internal/Socket.php Normal file
View File

@ -0,0 +1,233 @@
<?php
namespace Amp\DoH\Internal;
use Amp;
use Amp\ByteStream\ResourceInputStream;
use Amp\ByteStream\ResourceOutputStream;
use Amp\ByteStream\StreamException;
use Amp\Deferred;
use Amp\Dns\DnsException;
use Amp\Dns\TimeoutException;
use Amp\Promise;
use LibDNS\Messages\Message;
use LibDNS\Messages\MessageFactory;
use LibDNS\Messages\MessageTypes;
use LibDNS\Records\Question;
use function Amp\call;
/** @internal */
abstract class Socket
{
const MAX_CONCURRENT_REQUESTS = 500;
/** @var array Contains already sent queries with no response yet. For UDP this is exactly zero or one item. */
private $pending = [];
/** @var MessageFactory */
private $messageFactory;
/** @var callable */
private $onResolve;
/** @var bool */
private $receiving = false;
/** @var array Queued requests if the number of concurrent requests is too large. */
private $queue = [];
/**
* @param string $uri
*
* @return Promise<self>
*/
abstract public static function connect(Client $artax, Nameserver $nameserver): Socket;
/**
* @param Message $message
*
* @return Promise<int>
*/
abstract protected function send(Message $message): Promise;
/**
* @return Promise<Message>
*/
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;
}
}

44
lib/Nameserver.php Normal file
View File

@ -0,0 +1,44 @@
<?php
namespace Amp\DoH;
final class Nameserver
{
const RFC8484_GET = 0;
const RFC8484_POST = 1;
const GOOGLE_JSON = 2;
private $type;
private $uri;
private $headers = [];
public function __construct(string $uri, int $type = self::RFC8484_POST, array $headers = [])
{
$this->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";
}
}
}

379
lib/Rfc8484StubResolver.php Normal file
View File

@ -0,0 +1,379 @@
<?php
namespace Amp\DoH;
use Amp\Cache\ArrayCache;
use Amp\Cache\Cache;
use Amp\Dns\DnsException;
use Amp\Dns\NoRecordException;
use Amp\DoH\Internal\Socket;
use Amp\DoH\Internal\TcpSocket;
use Amp\DoH\Internal\UdpSocket;
use Amp\Loop;
use Amp\MultiReasonException;
use Amp\Promise;
use Amp\Success;
use function Amp\call;
use LibDNS\Messages\Message;
use LibDNS\Records\Question;
use LibDNS\Records\QuestionFactory;
use Amp\Dns\TimeoutException;
use Amp\Dns\Record;
use Amp\Dns\Resolver;
use Amp\DoH\Internal\HttpsSocket;
use Amp\Dns\ConfigLoader;
final class Rfc8484StubResolver implements Resolver
{
const CACHE_PREFIX = "amphp.doh.";
/** @var \Amp\Dns\ConfigLoader */
private $configLoader;
/** @var \LibDNS\Records\QuestionFactory */
private $questionFactory;
/** @var \Amp\Dns\Config|null */
private $config;
/** @var Promise|null */
private $pendingConfig;
/** @var \Amp\DoH\DoHConfig */
private $dohConfig;
/** @var Cache */
private $cache;
/** @var Promise[] */
private $pendingQueries = [];
public function __construct(DoHConfig $config, Cache $cache = null, ConfigLoader $configLoader = null)
{
$this->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()));
}
}
}

109
lib/functions.php Normal file
View File

@ -0,0 +1,109 @@
<?php
namespace Amp\DoH;
use Amp\Loop;
use Amp\Promise;
use Amp\Dns\Resolver;
use Amp\Dns\InvalidNameException;
const LOOP_STATE_IDENTIFIER = Resolver::class;
/**
* Retrieve the application-wide dns resolver instance.
*
* @param \Amp\DoH\Resolver $resolver Optionally specify a new default dns resolver instance
*
* @return \Amp\DoH\Resolver Returns the application-wide dns resolver instance
*/
function resolver(Resolver $resolver = null): Resolver
{
if ($resolver === null) {
$resolver = Loop::getState(LOOP_STATE_IDENTIFIER);
if ($resolver) {
return $resolver;
}
$resolver = createDefaultResolver();
}
Loop::setState(LOOP_STATE_IDENTIFIER, $resolver);
return $resolver;
}
/**
* Create a new dns resolver best-suited for the current environment.
*
* @return \Amp\DoH\Resolver
*/
function createDefaultResolver(): Resolver
{
return new Rfc1035StubResolver();
}
/**
* @see Resolver::resolve()
*/
function resolve(string $name, int $typeRestriction = null): Promise
{
return resolver()->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 = '/^(?<name>[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;
}

28
phpunit.xml.dist Normal file
View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/6.0/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
>
<testsuites>
<testsuite name="Main">
<directory>test</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">lib</directory>
</whitelist>
</filter>
<listeners>
<listener class="Amp\PHPUnit\LoopReset"/>
</listeners>
</phpunit>

77
test/ConfigTest.php Normal file
View File

@ -0,0 +1,77 @@
<?php
namespace Amp\Dns\Test;
use Amp\Dns\Config;
use Amp\Dns\ConfigException;
use Amp\PHPUnit\TestCase;
class ConfigTest extends TestCase
{
/**
* @param string[] $nameservers Valid server array.
*
* @dataProvider provideValidServers
*/
public function testAcceptsValidServers(array $nameservers)
{
$this->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);
}
}

23
test/DecodeTest.php Normal file
View File

@ -0,0 +1,23 @@
<?php
namespace Amp\Dns\Test;
use Amp\PHPUnit\TestCase;
use LibDNS\Decoder\DecoderFactory;
use LibDNS\Messages\Message;
class DecodeTest extends TestCase
{
/**
* Regression test for https://github.com/amphp/dns/issues/53 and other reported issues.
*/
public function testDecodesEmptyDomains()
{
$message = \hex2bin("37ed818000010005000d000005676d61696c03636f6d00000f0001c00c000f000100000dff0020000a04616c74310d676d61696c2d736d74702d696e016c06676f6f676c65c012c00c000f000100000dff0009001404616c7432c02ec00c000f000100000dff0009002804616c7434c02ec00c000f000100000dff0009001e04616c7433c02ec00c000f000100000dff00040005c02e0000020001000026b50014016c0c726f6f742d73657276657273036e6574000000020001000026b500040163c0a30000020001000026b500040164c0a30000020001000026b50004016ac0a30000020001000026b500040162c0a30000020001000026b500040161c0a30000020001000026b500040167c0a30000020001000026b50004016bc0a30000020001000026b500040165c0a30000020001000026b50004016dc0a30000020001000026b500040169c0a30000020001000026b500040166c0a30000020001000026b500040168c0a3");
$decoder = (new DecoderFactory)->create();
$response = $decoder->decode($message);
$this->assertInstanceOf(Message::class, $response);
}
}

43
test/HostLoaderTest.php Normal file
View File

@ -0,0 +1,43 @@
<?php
namespace Amp\Dns\Test;
use Amp\Dns\HostLoader;
use Amp\Dns\Record;
use Amp\Loop;
use Amp\PHPUnit\TestCase;
class HostLoaderTest extends TestCase
{
public function testIgnoresCommentsAndParsesBasicEntry()
{
Loop::run(function () {
$loader = new HostLoader(__DIR__ . "/data/hosts");
$this->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());
});
}
}

126
test/IntegrationTest.php Normal file
View File

@ -0,0 +1,126 @@
<?php
namespace Amp\Dns\Test;
use Amp\Dns;
use Amp\Dns\Record;
use Amp\Loop;
use Amp\PHPUnit\TestCase;
class IntegrationTest extends TestCase
{
/**
* @param string $hostname
* @group internet
* @dataProvider provideHostnames
*/
public function testResolve($hostname)
{
Loop::run(function () use ($hostname) {
$result = yield Dns\resolve($hostname);
/** @var Record $record */
$record = $result[0];
$inAddr = @\inet_pton($record->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"],
];
}
}

27
test/RecordTest.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace Amp\Dns\Test;
use Amp\Dns\Record;
use Amp\PHPUnit\TestCase;
class RecordTest extends TestCase
{
public function testGetName()
{
$this->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));
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace Amp\Dns\Test;
use Amp\Dns\DnsException;
use Amp\Dns\InvalidNameException;
use Amp\Dns\Record;
use Amp\Dns\Rfc1035StubResolver;
use Amp\Loop;
use Amp\PHPUnit\TestCase;
class Rfc1035StubResolverTest extends TestCase
{
public function testResolveSecondParameterAcceptedValues()
{
Loop::run(function () {
$this->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);
});
}
}

46
test/SocketTest.php Normal file
View File

@ -0,0 +1,46 @@
<?php
namespace Amp\Dns\Test;
use Amp\Dns;
use Amp\Loop;
use Amp\PHPUnit\TestCase;
use Amp\Promise;
use LibDNS\Messages\Message;
use LibDNS\Messages\MessageTypes;
use LibDNS\Records\QuestionFactory;
abstract class SocketTest extends TestCase
{
abstract protected function connect(): Promise;
public function testAsk()
{
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, 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());
});
}
}

57
test/TcpSocketTest.php Normal file
View File

@ -0,0 +1,57 @@
<?php
namespace Amp\Dns\Test;
use Amp\Delayed;
use Amp\Dns;
use Amp\Loop;
use Amp\Promise;
use LibDNS\Messages\Message;
use LibDNS\Messages\MessageTypes;
use LibDNS\Records\QuestionFactory;
use function Amp\Promise\wait;
class TcpSocketTest extends SocketTest
{
protected function connect(): Promise
{
return Dns\Internal\TcpSocket::connect("tcp://8.8.8.8:53");
}
public function testTimeout()
{
$this->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);
});
}
}

21
test/UdpSocketTest.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace Amp\Dns\Test;
use Amp\Dns;
use Amp\Promise;
use function Amp\Promise\wait;
class UdpSocketTest extends SocketTest
{
protected function connect(): Promise
{
return Dns\Internal\UdpSocket::connect("udp://8.8.8.8:53");
}
public function testInvalidUri()
{
$this->expectException(Dns\DnsException::class);
wait(Dns\Internal\UdpSocket::connect("udp://8.8.8.8"));
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Amp\Dns\Test;
use Amp\Dns\Config;
use Amp\Dns\ConfigException;
use Amp\Dns\UnixConfigLoader;
use Amp\PHPUnit\TestCase;
use function Amp\Promise\wait;
class UnixConfigLoaderTest extends TestCase
{
public function test()
{
$loader = new UnixConfigLoader(__DIR__ . "/data/resolv.conf");
/** @var Config $result */
$result = wait($loader->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());
}
}

2
test/data/hosts Normal file
View File

@ -0,0 +1,2 @@
# localhost
127.0.0.1 localhost

View File

@ -0,0 +1,2 @@
# aaa...aa is too long
127.0.0.1 localhost aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

14
test/data/resolv.conf Normal file
View File

@ -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