1
0
mirror of https://github.com/danog/dns.git synced 2024-11-30 04:29:06 +01:00
This commit is contained in:
Daniil Gentili 2019-08-22 20:39:13 +02:00
commit acf6e83e3c
10 changed files with 492 additions and 68 deletions

View File

@ -4,10 +4,20 @@ namespace Amp\Dns;
final class Config final class Config
{ {
/** @var array */
private $nameservers; private $nameservers;
/** @var array */
private $knownHosts; private $knownHosts;
/** @var int */
private $timeout; private $timeout;
/** @var int */
private $attempts; private $attempts;
/** @var array */
private $searchList = [];
/** @var int */
private $ndots = 1;
/** @var bool */
private $rotation = false;
public function __construct(array $nameservers, array $knownHosts = [], int $timeout = 3000, int $attempts = 2) public function __construct(array $nameservers, array $knownHosts = [], int $timeout = 3000, int $attempts = 2)
{ {
@ -44,6 +54,39 @@ final class Config
$this->attempts = $attempts; $this->attempts = $attempts;
} }
public function withSearchList(array $searchList): self
{
$self = clone $this;
$self->searchList = $searchList;
return $self;
}
/**
* @throws ConfigException
*/
public function withNdots(int $ndots): self
{
if ($ndots < 0) {
throw new ConfigException("Invalid ndots ({$ndots}), must be greater or equal to 0");
}
if ($ndots > 15) {
$ndots = 15;
}
$self = clone $this;
$self->ndots = $ndots;
return $self;
}
public function withRotationEnabled(bool $enabled = true): self
{
$self = clone $this;
$self->rotation = $enabled;
return $self;
}
private function validateNameserver($nameserver) private function validateNameserver($nameserver)
{ {
if (!$nameserver || !\is_string($nameserver)) { if (!$nameserver || !\is_string($nameserver)) {
@ -101,4 +144,19 @@ final class Config
{ {
return $this->attempts; return $this->attempts;
} }
public function getSearchList(): array
{
return $this->searchList;
}
public function getNdots(): int
{
return $this->ndots;
}
public function isRotationEnabled(): bool
{
return $this->rotation;
}
} }

View File

@ -55,6 +55,8 @@ final class Rfc1035StubResolver implements Resolver
/** @var BlockingFallbackResolver */ /** @var BlockingFallbackResolver */
private $blockingFallbackResolver; private $blockingFallbackResolver;
/** @var int */
private $nextNameserver = 0;
public function __construct(Cache $cache = null, ConfigLoader $configLoader = null) public function __construct(Cache $cache = null, ConfigLoader $configLoader = null)
{ {
@ -135,7 +137,9 @@ final class Rfc1035StubResolver implements Resolver
} }
break; break;
} }
$dots = \substr_count($name, ".");
// Should be replaced with $name[-1] from 7.1
$trailingDot = \substr($name, -1, 1) === ".";
$name = normalizeName($name); $name = normalizeName($name);
if ($records = $this->queryHosts($name, $typeRestriction)) { if ($records = $this->queryHosts($name, $typeRestriction)) {
@ -150,52 +154,69 @@ final class Rfc1035StubResolver implements Resolver
: [new Record('127.0.0.1', Record::A, null)]; : [new Record('127.0.0.1', Record::A, null)];
} }
for ($redirects = 0; $redirects < 5; $redirects++) { if (!$dots && \count($this->config->getSearchList()) === 0) {
try { throw new DnsException("Giving up resolution of '{$name}', unknown host");
if ($typeRestriction) { }
return yield $this->query($name, $typeRestriction);
$searchList = [null];
if (!$trailingDot && $dots < $this->config->getNdots()) {
$searchList = \array_merge($this->config->getSearchList(), $searchList);
}
foreach ($searchList as $search) {
for ($redirects = 0; $redirects < 5; $redirects++) {
$searchName = $name;
if ($search !== null) {
$searchName = $name . "." . $search;
} }
try { try {
list(, $records) = yield Promise\some([ if ($typeRestriction) {
$this->query($name, Record::A), return yield $this->query($searchName, $typeRestriction);
$this->query($name, Record::AAAA),
]);
return \array_merge(...$records);
} catch (MultiReasonException $e) {
$errors = [];
foreach ($e->getReasons() as $reason) {
if ($reason instanceof NoRecordException) {
throw $reason;
}
$errors[] = $reason->getMessage();
} }
throw new DnsException( try {
"All query attempts failed for {$name}: " . \implode(", ", $errors), list(, $records) = yield Promise\some([
0, $this->query($searchName, Record::A),
$e $this->query($searchName, Record::AAAA),
); ]);
}
} catch (NoRecordException $e) { return \array_merge(...$records);
try { } catch (MultiReasonException $e) {
/** @var Record[] $cnameRecords */ $errors = [];
$cnameRecords = yield $this->query($name, Record::CNAME);
$name = $cnameRecords[0]->getValue(); foreach ($e->getReasons() as $reason) {
continue; if ($reason instanceof NoRecordException) {
throw $reason;
}
$errors[] = $reason->getMessage();
}
throw new DnsException(
"All query attempts failed for {$searchName}: " . \implode(", ", $errors),
0,
$e
);
}
} catch (NoRecordException $e) { } catch (NoRecordException $e) {
/** @var Record[] $dnameRecords */ try {
$dnameRecords = yield $this->query($name, Record::DNAME); /** @var Record[] $cnameRecords */
$name = $dnameRecords[0]->getValue(); $cnameRecords = yield $this->query($searchName, Record::CNAME);
continue; $name = $cnameRecords[0]->getValue();
continue;
} catch (NoRecordException $e) {
/** @var Record[] $dnameRecords */
$dnameRecords = yield $this->query($searchName, Record::DNAME);
$name = $dnameRecords[0]->getValue();
continue;
}
} }
} }
} }
throw new DnsException("Giving up resolution of '{$name}', too many redirects"); throw new DnsException("Giving up resolution of '{$searchName}', too many redirects");
}); });
} }
@ -268,7 +289,8 @@ final class Rfc1035StubResolver implements Resolver
return $this->decodeCachedResult($name, $type, $cachedValue); return $this->decodeCachedResult($name, $type, $cachedValue);
} }
$nameservers = $this->config->getNameservers(); $nameservers = $this->selectNameservers();
$nameserversCount = \count($nameservers);
$attempts = $this->config->getAttempts(); $attempts = $this->config->getAttempts();
$protocol = "udp"; $protocol = "udp";
$attempt = 0; $attempt = 0;
@ -285,9 +307,7 @@ final class Rfc1035StubResolver implements Resolver
unset($this->sockets[$uri]); unset($this->sockets[$uri]);
$socket->close(); $socket->close();
/** @var Socket $server */ $uri = $protocol . "://" . $nameservers[$attempt % $nameserversCount];
$i = $attempt % \count($nameservers);
$uri = $protocol . "://" . $nameservers[$i];
$socket = yield $this->getSocket($uri); $socket = yield $this->getSocket($uri);
} }
@ -309,8 +329,7 @@ final class Rfc1035StubResolver implements Resolver
if ($protocol !== "tcp") { if ($protocol !== "tcp") {
// Retry with TCP, don't count attempt // Retry with TCP, don't count attempt
$protocol = "tcp"; $protocol = "tcp";
$i = $attempt % \count($nameservers); $uri = $protocol . "://" . $nameservers[$attempt % $nameserversCount];
$uri = $protocol . "://" . $nameservers[$i];
$socket = yield $this->getSocket($uri); $socket = yield $this->getSocket($uri);
continue; continue;
} }
@ -356,8 +375,7 @@ final class Rfc1035StubResolver implements Resolver
$socket->close(); $socket->close();
}); });
$i = ++$attempt % \count($nameservers); $uri = $protocol . "://" . $nameservers[++$attempt % $nameserversCount];
$uri = $protocol . "://" . $nameservers[$i];
$socket = yield $this->getSocket($uri); $socket = yield $this->getSocket($uri);
continue; continue;
@ -488,10 +506,28 @@ final class Rfc1035StubResolver implements Resolver
return $server; return $server;
} }
/**
* @throws DnsException
*/
private function assertAcceptableResponse(Message $response) private function assertAcceptableResponse(Message $response)
{ {
if ($response->getResponseCode() !== 0) { if ($response->getResponseCode() !== 0) {
throw new DnsException(\sprintf("Server returned error code: %d", $response->getResponseCode())); throw new DnsException(\sprintf("Server returned error code: %d", $response->getResponseCode()));
} }
} }
private function selectNameservers(): array
{
$nameservers = $this->config->getNameservers();
if ($this->config->isRotationEnabled() && ($nameserversCount = \count($nameservers)) > 1) {
$nameservers = \array_merge(
\array_slice($nameservers, $this->nextNameserver),
\array_slice($nameservers, 0, $this->nextNameserver)
);
$this->nextNameserver = ++$this->nextNameserver % $nameserversCount;
}
return $nameservers;
}
} }

View File

@ -9,6 +9,23 @@ use function Amp\call;
class UnixConfigLoader implements ConfigLoader class UnixConfigLoader implements ConfigLoader
{ {
const MAX_NAMESERVERS = 3;
const MAX_DNS_SEARCH = 6;
const MAX_TIMEOUT = 30 * 1000;
const MAX_ATTEMPTS = 5;
const MAX_NDOTS = 15;
const DEFAULT_TIMEOUT = 5 * 1000;
const DEFAULT_ATTEMPTS = 2;
const DEFAULT_NDOTS = 1;
const DEFAULT_OPTIONS = [
"timeout" => self::DEFAULT_TIMEOUT,
"attempts" => self::DEFAULT_ATTEMPTS,
"ndots" => self::DEFAULT_NDOTS,
"rotate" => false,
];
private $path; private $path;
private $hostLoader; private $hostLoader;
@ -40,8 +57,20 @@ class UnixConfigLoader implements ConfigLoader
{ {
return call(function () { return call(function () {
$nameservers = []; $nameservers = [];
$timeout = 3000; $searchList = [];
$attempts = 2; $options = self::DEFAULT_OPTIONS;
$haveLocaldomainEnv = false;
/* Allow user to override the local domain definition. */
if ($localdomain = \getenv("LOCALDOMAIN")) {
/* Set search list to be blank-separated strings from rest of
env value. Permits users of LOCALDOMAIN to still have a
search list, and anyone to set the one that they want to use
as an individual (even more important now that the rfc1535
stuff restricts searches). */
$searchList = $this->splitOnWhitespace($localdomain);
$haveLocaldomainEnv = true;
}
$fileContent = yield $this->readFile($this->path); $fileContent = yield $this->readFile($this->path);
@ -57,6 +86,9 @@ class UnixConfigLoader implements ConfigLoader
list($type, $value) = $line; list($type, $value) = $line;
if ($type === "nameserver") { if ($type === "nameserver") {
if (\count($nameservers) === self::MAX_NAMESERVERS) {
continue;
}
$value = \trim($value); $value = \trim($value);
$ip = @\inet_pton($value); $ip = @\inet_pton($value);
@ -69,29 +101,90 @@ class UnixConfigLoader implements ConfigLoader
} else { // IPv4 } else { // IPv4
$nameservers[] = $value . ":53"; $nameservers[] = $value . ":53";
} }
} elseif ($type === "domain" && !$haveLocaldomainEnv) { // LOCALDOMAIN env overrides config
$searchList = $this->splitOnWhitespace($value);
} elseif ($type === "search" && !$haveLocaldomainEnv) { // LOCALDOMAIN env overrides config
$searchList = $this->splitOnWhitespace($value);
} elseif ($type === "options") { } elseif ($type === "options") {
$optline = \preg_split('#\s+#', $value, 2); $option = $this->parseOption($value);
if (\count($option) === 2) {
if (\count($optline) !== 2) { $options[$option[0]] = $option[1];
continue;
}
list($option, $value) = $optline;
switch ($option) {
case "timeout":
$timeout = (int) $value;
break;
case "attempts":
$attempts = (int) $value;
} }
} }
} }
$hosts = yield $this->hostLoader->loadHosts(); $hosts = yield $this->hostLoader->loadHosts();
return new Config($nameservers, $hosts, $timeout, $attempts); if (\count($searchList) === 0) {
$hostname = \gethostname();
$dot = \strpos(".", $hostname);
if ($dot !== false && $dot < \strlen($hostname)) {
$searchList = [
\substr($hostname, $dot),
];
}
}
if (\count($searchList) > self::MAX_DNS_SEARCH) {
$searchList = \array_slice($searchList, 0, self::MAX_DNS_SEARCH);
}
$resOptions = \getenv("RES_OPTIONS");
if ($resOptions) {
foreach ($this->splitOnWhitespace($resOptions) as $option) {
$option = $this->parseOption($option);
if (\count($option) === 2) {
$options[$option[0]] = $option[1];
}
}
}
$config = new Config($nameservers, $hosts, $options["timeout"], $options["attempts"]);
return $config->withSearchList($searchList)
->withNdots($options["ndots"])
->withRotationEnabled($options["rotate"]);
}); });
} }
private function splitOnWhitespace(string $names): array
{
return \preg_split("#\s+#", \trim($names));
}
private function parseOption(string $option): array
{
$optline = \explode(':', $option, 2);
list($name, $value) = $optline + [1 => null];
switch ($name) {
case "timeout":
$value = (int) $value;
if ($value < 0) {
return []; // don't overwrite option value
}
// The value for this option is silently capped to 30s
return ["timeout", (int) \min($value * 1000, self::MAX_TIMEOUT)];
case "attempts":
$value = (int) $value;
if ($value < 0) {
return []; // don't overwrite option value
}
// The value for this option is silently capped to 5
return ["attempts", (int) \min($value, self::MAX_ATTEMPTS)];
case "ndots":
$value = (int) $value;
if ($value < 0) {
return []; // don't overwrite option value
}
// The value for this option is silently capped to 15
return ["ndots", (int) \min($value, self::MAX_NDOTS)];
case "rotate":
return ["rotate", true];
}
return [];
}
} }

View File

@ -74,4 +74,38 @@ class ConfigTest extends TestCase
$this->expectException(ConfigException::class); $this->expectException(ConfigException::class);
new Config(["127.0.0.1"], [], 500, 0); new Config(["127.0.0.1"], [], 500, 0);
} }
public function testInvalidNtods()
{
$this->expectException(ConfigException::class);
$config = new Config(["127.0.0.1"]);
$config->withNdots(-1);
}
public function testNdots()
{
$config = new Config(["127.0.0.1"]);
$config = $config->withNdots(1);
$this->assertSame(1, $config->getNdots());
}
public function testSearchList()
{
$config = new Config(["127.0.0.1"]);
$config = $config->withSearchList(['local']);
$this->assertSame(['local'], $config->getSearchList());
}
public function testRotationEnabled()
{
$config = new Config(["127.0.0.1"]);
$config = $config->withRotationEnabled(true);
$this->assertTrue($config->isRotationEnabled());
}
public function testRotationDisabled()
{
$config = new Config(["127.0.0.1"]);
$this->assertFalse($config->isRotationEnabled());
}
} }

View File

@ -2,11 +2,17 @@
namespace Amp\Dns\Test; namespace Amp\Dns\Test;
use Amp\Cache\NullCache;
use Amp\Dns; use Amp\Dns;
use Amp\Dns\BlockingFallbackResolver; use Amp\Dns\BlockingFallbackResolver;
use Amp\Dns\DnsException;
use Amp\Dns\Record; use Amp\Dns\Record;
use Amp\Dns\UnixConfigLoader;
use Amp\Dns\WindowsConfigLoader;
use Amp\Loop; use Amp\Loop;
use Amp\PHPUnit\TestCase; use Amp\PHPUnit\TestCase;
use Amp\Success;
use PHPUnit\Framework\MockObject\MockObject;
class IntegrationTest extends TestCase class IntegrationTest extends TestCase
{ {
@ -25,7 +31,7 @@ class IntegrationTest extends TestCase
$inAddr = @\inet_pton($record->getValue()); $inAddr = @\inet_pton($record->getValue());
$this->assertNotFalse( $this->assertNotFalse(
$inAddr, $inAddr,
"Server name $hostname did not resolve to a valid IP address" "Server name {$hostname} did not resolve to a valid IP address"
); );
}); });
} }
@ -95,6 +101,87 @@ class IntegrationTest extends TestCase
}); });
} }
public function testResolveUsingSearchList()
{
Loop::run(function () {
$configLoader = \stripos(PHP_OS, "win") === 0
? new WindowsConfigLoader()
: new UnixConfigLoader();
/** @var Dns\Config $config */
$config = yield $configLoader->loadConfig();
$config = $config->withSearchList(['kelunik.com']);
$config = $config->withNdots(1);
/** @var Dns\ConfigLoader|MockObject $configLoader */
$configLoader = $this->createMock(Dns\ConfigLoader::class);
$configLoader->expects($this->once())
->method('loadConfig')
->willReturn(new Success($config));
Dns\resolver(new Dns\Rfc1035StubResolver(null, $configLoader));
$result = yield Dns\resolve('blog');
/** @var Record $record */
$record = $result[0];
$inAddr = @\inet_pton($record->getValue());
$this->assertNotFalse(
$inAddr,
"Server name blog.kelunik.com did not resolve to a valid IP address"
);
$result = yield Dns\query('blog.kelunik.com', Dns\Record::A);
/** @var Record $record */
$record = $result[0];
$this->assertSame($inAddr, @\inet_pton($record->getValue()));
});
}
public function testFailResolveRootedDomainWhenSearchListDefined()
{
Loop::run(function () {
$configLoader = \stripos(PHP_OS, "win") === 0
? new WindowsConfigLoader()
: new UnixConfigLoader();
/** @var Dns\Config $config */
$config = yield $configLoader->loadConfig();
$config = $config->withSearchList(['kelunik.com']);
$config = $config->withNdots(1);
/** @var Dns\ConfigLoader|MockObject $configLoader */
$configLoader = $this->createMock(Dns\ConfigLoader::class);
$configLoader->expects($this->once())
->method('loadConfig')
->willReturn(new Success($config));
Dns\resolver(new Dns\Rfc1035StubResolver(null, $configLoader));
$this->expectException(DnsException::class);
yield Dns\resolve('blog.');
});
}
public function testResolveWithRotateList()
{
Loop::run(function () {
/** @var Dns\ConfigLoader|MockObject $configLoader */
$configLoader = $this->createMock(Dns\ConfigLoader::class);
$config = new Dns\Config([
'208.67.222.220:53', // Opendns, US
'195.243.214.4:53', // Deutche Telecom AG, DE
]);
$config = $config->withRotationEnabled(true);
$configLoader->expects($this->once())
->method('loadConfig')
->willReturn(new Success($config));
$resolver = new Dns\Rfc1035StubResolver(new NullCache(), $configLoader);
/** @var Record $record1 */
list($record1) = yield $resolver->query('facebook.com', Dns\Record::A);
/** @var Record $record2 */
list($record2) = yield $resolver->query('facebook.com', Dns\Record::A);
$this->assertNotSame($record1->getValue(), $record2->getValue());
});
}
public function testPtrLookup() public function testPtrLookup()
{ {
Loop::run(function () { Loop::run(function () {
@ -179,6 +266,7 @@ class IntegrationTest extends TestCase
["localhost"], ["localhost"],
["192.168.0.1"], ["192.168.0.1"],
["::1"], ["::1"],
["dns.google."], /* that's rooted domain name - cannot use searchList */
]; ];
} }

View File

@ -22,8 +22,87 @@ class UnixConfigLoaderTest extends TestCase
"[2001:4860:4860::8888]:53", "[2001:4860:4860::8888]:53",
], $result->getNameservers()); ], $result->getNameservers());
$this->assertSame(5000, $result->getTimeout()); $this->assertSame(30000, $result->getTimeout());
$this->assertSame(3, $result->getAttempts()); $this->assertSame(3, $result->getAttempts());
$this->assertEmpty($result->getSearchList());
$this->assertSame(1, $result->getNdots());
$this->assertFalse($result->isRotationEnabled());
}
public function testWithSearchList()
{
$loader = new UnixConfigLoader(__DIR__ . "/data/resolv-search.conf");
/** @var Config $result */
$result = wait($loader->loadConfig());
$this->assertSame([
"127.0.0.1:53",
"[2001:4860:4860::8888]:53",
], $result->getNameservers());
$this->assertSame(30000, $result->getTimeout());
$this->assertSame(3, $result->getAttempts());
$this->assertSame(['local', 'local1', 'local2', 'local3', 'local4', 'local5'], $result->getSearchList());
$this->assertSame(15, $result->getNdots());
$this->assertFalse($result->isRotationEnabled());
}
public function testWithRotateOption()
{
$loader = new UnixConfigLoader(__DIR__ . "/data/resolv-rotate.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(2, $result->getAttempts());
$this->assertTrue($result->isRotationEnabled());
}
public function testWithNegativeOption()
{
$loader = new UnixConfigLoader(__DIR__ . "/data/resolv-negative-option-values.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(2, $result->getAttempts());
$this->assertSame(1, $result->getNdots());
}
public function testWithEnvironmentOverride()
{
\putenv("LOCALDOMAIN=local");
\putenv("RES_OPTIONS=timeout:1 attempts:10 ndots:10 rotate");
$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(['local'], $result->getSearchList());
$this->assertSame(1000, $result->getTimeout());
$this->assertSame(5, $result->getAttempts());
$this->assertSame(10, $result->getNdots());
$this->assertTrue($result->isRotationEnabled());
} }
public function testNoDefaultsOnConfNotFound() public function testNoDefaultsOnConfNotFound()

View File

@ -0,0 +1,9 @@
# Default
nameserver 127.0.0.1
# Google Fallback
nameserver 2001:4860:4860::8888
options timeout:-1
options attempts:-1
options ndots:-1

View File

@ -0,0 +1,10 @@
# Default
nameserver 127.0.0.1
# Google Fallback
nameserver 2001:4860:4860::8888
# Invalid server gets ignored
nameserver foobar
options rotate

View File

@ -0,0 +1,17 @@
# Default
nameserver 127.0.0.1
# Google Fallback
nameserver 2001:4860:4860::8888
# Invalid server gets ignored
nameserver foobar
search local local1 local2 local3 local4 local5 local6
options timeout:5000
options attempts:3
options ndots:20
# Unknown option gets ignored
options foo

View File

@ -7,8 +7,8 @@ nameserver 2001:4860:4860::8888
# Invalid server gets ignored # Invalid server gets ignored
nameserver foobar nameserver foobar
options timeout 5000 options timeout:5000
options attempts 3 options attempts:3
# Unknown option gets ignored # Unknown option gets ignored
options foo options foo