mirror of
https://github.com/danog/dns.git
synced 2024-11-26 20:14:51 +01:00
1a41089d84
Previously we queried domains usually five times (max redirects). It didn't have much impact, because these records were usually cached and immediately returned again, but this resulted in the TTL always being NULL due to cached records being returned.
393 lines
13 KiB
PHP
393 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Amp\Dns;
|
|
|
|
use Amp\Cache\ArrayCache;
|
|
use Amp\Cache\Cache;
|
|
use Amp\Dns\Internal\Socket;
|
|
use Amp\Dns\Internal\TcpSocket;
|
|
use Amp\Dns\Internal\UdpSocket;
|
|
use Amp\Loop;
|
|
use Amp\MultiReasonException;
|
|
use Amp\Promise;
|
|
use Amp\Success;
|
|
use LibDNS\Messages\Message;
|
|
use LibDNS\Records\Question;
|
|
use LibDNS\Records\QuestionFactory;
|
|
use function Amp\call;
|
|
use function Amp\Uri\normalizeDnsName;
|
|
|
|
final class BasicResolver implements Resolver {
|
|
const CACHE_PREFIX = "amphp.dns.";
|
|
|
|
/** @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 Cache */
|
|
private $cache;
|
|
|
|
/** @var Socket[] */
|
|
private $sockets = [];
|
|
|
|
/** @var Promise[] */
|
|
private $pendingSockets = [];
|
|
|
|
/** @var Promise[] */
|
|
private $pendingQueries = [];
|
|
|
|
/** @var string */
|
|
private $gcWatcher;
|
|
|
|
public function __construct(Cache $cache = null, ConfigLoader $configLoader = null) {
|
|
$this->cache = $cache ?? new ArrayCache;
|
|
$this->configLoader = $configLoader ?? (\stripos(PHP_OS, "win") === 0
|
|
? new WindowsConfigLoader
|
|
: new UnixConfigLoader);
|
|
|
|
$this->questionFactory = new QuestionFactory;
|
|
|
|
$this->gcWatcher = Loop::repeat(5000, function () {
|
|
if (empty($this->sockets)) {
|
|
return;
|
|
}
|
|
|
|
$now = \time();
|
|
|
|
foreach ($this->sockets as $key => $server) {
|
|
if ($server->getLastActivity() < $now - 60) {
|
|
$server->close();
|
|
unset($this->sockets[$key]);
|
|
}
|
|
}
|
|
});
|
|
|
|
Loop::unreference($this->gcWatcher);
|
|
}
|
|
|
|
public function __destruct() {
|
|
Loop::cancel($this->gcWatcher);
|
|
}
|
|
|
|
/** @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();
|
|
}
|
|
|
|
$inAddr = @\inet_pton($name);
|
|
|
|
if ($inAddr !== false) {
|
|
// It's already a valid IP, don't query, immediately return
|
|
if ($typeRestriction) {
|
|
if ($typeRestriction === Record::A && isset($inAddr[4])) {
|
|
throw new ResolutionException("Got an IPv6 address, but type is restricted to IPv4");
|
|
}
|
|
|
|
if ($typeRestriction === Record::AAAA && !isset($inAddr[4])) {
|
|
throw new ResolutionException("Got an IPv4 address, but type is restricted to IPv6");
|
|
}
|
|
}
|
|
|
|
return [
|
|
new Record($name, isset($inAddr[4]) ? Record::AAAA : Record::A, null),
|
|
];
|
|
}
|
|
|
|
$name = normalizeDnsName($name);
|
|
|
|
if ($records = $this->queryHosts($name, $typeRestriction)) {
|
|
return $records;
|
|
}
|
|
|
|
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) {
|
|
foreach ($e->getReasons() as $reason) {
|
|
if ($reason instanceof NoRecordException) {
|
|
throw $reason;
|
|
}
|
|
}
|
|
|
|
throw new ResolutionException("All query attempts failed", 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;
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
$nameservers = $this->config->getNameservers();
|
|
$attempts = $this->config->getAttempts();
|
|
$protocol = "udp";
|
|
$attempt = 0;
|
|
|
|
/** @var Socket $socket */
|
|
$uri = $protocol . "://" . $nameservers[0];
|
|
$socket = yield $this->getSocket($uri);
|
|
|
|
while ($attempt < $attempts) {
|
|
try {
|
|
if (!$socket->isAlive()) {
|
|
unset($this->sockets[$uri]);
|
|
$socket->close();
|
|
|
|
/** @var Socket $server */
|
|
$i = $attempt % \count($nameservers);
|
|
$socket = yield $this->getSocket($protocol . "://" . $nameservers[$i]);
|
|
}
|
|
|
|
/** @var Message $response */
|
|
$response = yield $socket->ask($question, $this->config->getTimeout());
|
|
$this->assertAcceptableResponse($response);
|
|
|
|
if ($response->isTruncated()) {
|
|
if ($protocol !== "tcp") {
|
|
// Retry with TCP, don't count attempt
|
|
$protocol = "tcp";
|
|
$i = $attempt % \count($nameservers);
|
|
$socket = yield $this->getSocket($protocol . "://" . $nameservers[$i]);
|
|
continue;
|
|
}
|
|
|
|
throw new ResolutionException("Server returned truncated response");
|
|
}
|
|
|
|
$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}");
|
|
}
|
|
|
|
return \array_map(function ($data) use ($type, $ttls) {
|
|
return new Record($data, $type, $ttls[$type]);
|
|
}, $result[$type]);
|
|
} catch (TimeoutException $e) {
|
|
$i = ++$attempt % \count($nameservers);
|
|
$socket = yield $this->getSocket($protocol . "://" . $nameservers[$i]);
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
throw new TimeoutException("No response from any nameserver after {$attempts} attempts");
|
|
});
|
|
|
|
$this->pendingQueries[$type . " " . $name] = $promise;
|
|
$promise->onResolve(function () use ($name, $type) {
|
|
unset($this->pendingQueries[$type . " " . $name]);
|
|
});
|
|
|
|
return $promise;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* @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, string $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 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 = normalizeDnsName($name);
|
|
}
|
|
|
|
return $name;
|
|
}
|
|
|
|
private function getSocket($uri): Promise {
|
|
// We use a new socket for each UDP request, as that increases the entropy and mitigates response forgery.
|
|
if (\substr($uri, 0, 3) === "udp") {
|
|
return UdpSocket::connect($uri);
|
|
}
|
|
|
|
// Over TCP we might reuse sockets if the server allows to keep them open. Sequence IDs in TCP are already
|
|
// better than a random port. Additionally, a TCP connection is more expensive.
|
|
if (isset($this->sockets[$uri])) {
|
|
return new Success($this->sockets[$uri]);
|
|
}
|
|
|
|
if (isset($this->pendingSockets[$uri])) {
|
|
return $this->pendingSockets[$uri];
|
|
}
|
|
|
|
$server = TcpSocket::connect($uri);
|
|
|
|
$server->onResolve(function ($error, $server) use ($uri) {
|
|
unset($this->pendingSockets[$uri]);
|
|
|
|
if (!$error) {
|
|
$this->sockets[$uri] = $server;
|
|
}
|
|
});
|
|
|
|
return $server;
|
|
}
|
|
|
|
private function assertAcceptableResponse(Message $response) {
|
|
if ($response->getResponseCode() !== 0) {
|
|
throw new ResolutionException(\sprintf("Server returned error code: %d", $response->getResponseCode()));
|
|
}
|
|
}
|
|
}
|