From e3411ce4725f778a68b15669210ebff3071d39fd Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 19 Mar 2016 22:23:55 +0100 Subject: [PATCH] Refactor into class with accessor like in amphp/amp and amphp/file, resolves #28 --- lib/Resolver.php | 48 ++++ lib/SystemResolver.php | 597 ++++++++++++++++++++++++++++++++++++++++ lib/functions.php | 593 ++------------------------------------- test/ResolvConfTest.php | 14 +- 4 files changed, 684 insertions(+), 568 deletions(-) create mode 100644 lib/Resolver.php create mode 100644 lib/SystemResolver.php diff --git a/lib/Resolver.php b/lib/Resolver.php new file mode 100644 index 0000000..3869373 --- /dev/null +++ b/lib/Resolver.php @@ -0,0 +1,48 @@ +messageFactory = new MessageFactory; + $this->questionFactory = new QuestionFactory; + $this->encoder = (new EncoderFactory)->create(); + $this->decoder = (new DecoderFactory)->create(); + $this->arrayCache = new ArrayCache; + $this->requestIdCounter = 1; + $this->pendingRequests = []; + $this->serverIdMap = []; + $this->serverUriMap = []; + $this->serverIdTimeoutMap = []; + $this->now = \time(); + $this->serverTimeoutWatcher = \Amp\repeat(function ($watcherId) { + $this->now = $now = \time(); + foreach ($this->serverIdTimeoutMap as $id => $expiry) { + if ($now > $expiry) { + $this->unloadServer($id); + } + } + if (empty($this->serverIdMap)) { + \Amp\disable($watcherId); + } + }, 1000, $options = [ + "enable" => true, + "keep_alive" => false, + ]); + } + + /** + * {@inheritdoc} + */ + public function resolve($name, array $options = []) { + if (!$inAddr = @\inet_pton($name)) { + if ($this->isValidHostName($name)) { + $types = empty($options["types"]) ? [Record::A, Record::AAAA] : $options["types"]; + return $this->pipeResult($this->recurseWithHosts($name, $types, $options), $types); + } else { + return new Failure(new ResolutionException("Cannot resolve; invalid host name")); + } + } else { + return new Success([[$name, isset($inAddr[4]) ? Record::AAAA : Record::A, $ttl = null]]); + } + } + + /** + * {$inheritdoc} + */ + public function query($name, $type, array $options = []) { + $handler = [$this, empty($options["recurse"]) ? "doResolve" : "doRecurse"]; + $types = (array) $type; + return $this->pipeResult(\Amp\resolve($handler($name, $types, $options)), $types); + } + + private function isValidHostName($name) { + $pattern = "/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9]){0,1})(?:\\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*$/i"; + + return !isset($name[253]) && \preg_match($pattern, $name); + } + + // flatten $result while preserving order according to $types (append unspecified types for e.g. Record::ALL queries) + private function pipeResult($promise, array $types) { + return \Amp\pipe($promise, function (array $result) use ($types) { + $retval = []; + foreach ($types as $type) { + if (isset($result[$type])) { + $retval = \array_merge($retval, $result[$type]); + unset($result[$type]); + } + } + return $result ? \array_merge($retval, \call_user_func_array("array_merge", $result)) : $retval; + }); + } + + private function recurseWithHosts($name, array $types, $options) { + // Check for hosts file matches + if (!isset($options["hosts"]) || $options["hosts"]) { + static $hosts = null; + if ($hosts === null || !empty($options["reload_hosts"])) { + return \Amp\pipe(\Amp\resolve($this->loadHostsFile()), function ($value) use (&$hosts, $name, $types, $options) { + unset($options["reload_hosts"]); // avoid recursion + $hosts = $value; + return $this->recurseWithHosts($name, $types, $options); + }); + } + $result = []; + if (in_array(Record::A, $types) && isset($hosts[Record::A][$name])) { + $result[Record::A] = [[$hosts[Record::A][$name], Record::A, $ttl = null]]; + } + if (in_array(Record::AAAA, $types) && isset($hosts[Record::AAAA][$name])) { + $result[Record::AAAA] = [[$hosts[Record::AAAA][$name], Record::AAAA, $ttl = null]]; + } + if ($result) { + return new Success($result); + } + } + + return \Amp\resolve($this->doRecurse($name, $types, $options)); + } + + private function doRecurse($name, array $types, $options) { + if (array_intersect($types, [Record::CNAME, Record::DNAME])) { + throw new ResolutionException("Cannot use recursion for CNAME and DNAME records"); + } + + $types = array_merge($types, [Record::CNAME, Record::DNAME]); + $lookupName = $name; + for ($i = 0; $i < 30; $i++) { + $result = (yield \Amp\resolve($this->doResolve($lookupName, $types, $options))); + if (count($result) > isset($result[Record::CNAME]) + isset($result[Record::DNAME])) { + unset($result[Record::CNAME], $result[Record::DNAME]); + yield new CoroutineResult($result); + return; + } + // @TODO check for potentially using recursion and iterate over *all* CNAME/DNAME + // @FIXME check higher level for CNAME? + foreach ([Record::CNAME, Record::DNAME] as $type) { + if (isset($result[$type])) { + list($lookupName) = $result[$type][0]; + } + } + } + + throw new ResolutionException("CNAME or DNAME chain too long (possible recursion?)"); + } + + private function doRequest($uri, $name, $type) { + $server = $this->loadExistingServer($uri) ?: $this->loadNewServer($uri); + + // Get the next available request ID + do { + $requestId = $this->requestIdCounter++; + if ($this->requestIdCounter >= MAX_REQUEST_ID) { + $this->requestIdCounter = 1; + } + } while (isset($this->pendingRequests[$requestId])); + + // Create question record + $question = $this->questionFactory->create($type); + $question->setName($name); + + // Create request message + $request = $this->messageFactory->create(MessageTypes::QUERY); + $request->getQuestionRecords()->add($question); + $request->isRecursionDesired(true); + $request->setID($requestId); + + // Encode request message + $requestPacket = $this->encoder->encode($request); + + if (substr($uri, 0, 6) == "tcp://") { + $requestPacket = pack("n", strlen($requestPacket)) . $requestPacket; + } + + // Send request + $bytesWritten = \fwrite($server->socket, $requestPacket); + if ($bytesWritten === false || isset($packet[$bytesWritten])) { + throw new ResolutionException( + "Request send failed" + ); + } + + $promisor = new Deferred; + $server->pendingRequests[$requestId] = true; + $this->pendingRequests[$requestId] = [$promisor, $name, $type, $uri]; + + return $promisor->promise(); + } + + private function doResolve($name, array $types, $options) { + if (!$this->config) { + $this->config = (yield \Amp\resolve($this->loadResolvConf())); + } + + if (empty($types)) { + yield new CoroutineResult([]); + return; + } + + assert(array_reduce($types, function ($result, $val) { return $result && \is_int($val); }, true), 'The $types passed to DNS functions must all be integers (from \Amp\Dns\Record class)'); + + $name = \strtolower($name); + $result = []; + + // Check for cache hits + if (!isset($options["cache"]) || $options["cache"]) { + foreach ($types as $k => $type) { + $cacheKey = "$name#$type"; + $cacheValue = (yield $this->arrayCache->get($cacheKey)); + + if ($cacheValue !== null) { + $result[$type] = $cacheValue; + unset($types[$k]); + } + } + if (empty($types)) { + if (empty(array_filter($result))) { + throw new NoRecordException("No records returned for {$name} (cached result)"); + } else { + yield new CoroutineResult($result); + return; + } + } + } + + $timeout = empty($options["timeout"]) ? $this->config["timeout"] : (int) $options["timeout"]; + + if (empty($options["server"])) { + if (empty($this->config["nameservers"])) { + throw new ResolutionException("No nameserver specified in system config"); + } + + $uri = "udp://" . $this->config["nameservers"][0]; + } else { + $uri = $this->parseCustomServerUri($options["server"]); + } + + foreach ($types as $type) { + $promises[] = $this->doRequest($uri, $name, $type); + } + + try { + list( , $resultArr) = (yield \Amp\timeout(\Amp\some($promises), $timeout)); + foreach ($resultArr as $value) { + $result += $value; + } + } catch (\Amp\TimeoutException $e) { + if (substr($uri, 0, 6) == "tcp://") { + throw new TimeoutException( + "Name resolution timed out for {$name}" + ); + } else { + $options["server"] = \preg_replace("#[a-z.]+://#", "tcp://", $uri); + yield new CoroutineResult(\Amp\resolve($this->doResolve($name, $types, $options))); + return; + } + } catch (ResolutionException $e) { + if (empty($result)) { // if we have no cached results + throw $e; + } + } catch (CombinatorException $e) { // if all promises in Amp\some fail + if (empty($result)) { // if we have no cached results + foreach ($e->getExceptions() as $ex) { + if ($ex instanceof NoRecordException) { + throw new NoRecordException("No records returned for {$name}", 0, $e); + } + } + throw new ResolutionException("All name resolution requests failed", 0, $e); + } + } + + yield new CoroutineResult($result); + } + + /** @link http://man7.org/linux/man-pages/man5/resolv.conf.5.html */ + private function loadResolvConf($path = null) { + $result = [ + "nameservers" => [ + "8.8.8.8:53", + "8.8.4.4:53", + ], + "timeout" => 3000, + "attempts" => 2, + ]; + + if (\stripos(PHP_OS, "win") !== 0) { + $path = $path ?: "/etc/resolv.conf"; + + try { + $lines = explode("\n", (yield \Amp\File\get($path))); + $result["nameservers"] = []; + + foreach ($lines as $line) { + $line = \preg_split('#\s+#', $line, 2); + if (\count($line) !== 2) { + continue; + } + + list($type, $value) = $line; + if ($type === "nameserver") { + $line[1] = trim($line[1]); + $ip = @\inet_pton($line[1]); + + if ($ip === false) { + continue; + } + + if (isset($ip[15])) { + $result["nameservers"][] = "[" . $line[1] . "]:53"; + } else { + $result["nameservers"][] = $line[1] . ":53"; + } + } elseif ($type === "options") { + $optline = preg_split('#\s+#', $value, 2); + if (\count($optline) !== 2) { + continue; + } + + // TODO: Respect the contents of the attempts setting during resolution + + list($option, $value) = $optline; + if (in_array($option, ["timeout", "attempts"])) { + $result[$option] = (int) $value; + } + } + } + } catch (FilesystemException $e) { + // use default + } + } + + yield new CoroutineResult($result); + } + + private function loadHostsFile($path = null) { + $data = []; + if (empty($path)) { + $path = \stripos(PHP_OS, "win") === 0 + ? 'C:\Windows\system32\drivers\etc\hosts' + : '/etc/hosts'; + } + try { + $contents = (yield \Amp\File\get($path)); + } catch (\Exception $e) { + yield new CoroutineResult($data); + + return; + } + $lines = \array_filter(\array_map("trim", \explode("\n", $contents))); + foreach ($lines as $line) { + if ($line[0] === "#") { + continue; + } + $parts = \preg_split('/\s+/', $line); + if (!($ip = @\inet_pton($parts[0]))) { + continue; + } elseif (isset($ip[4])) { + $key = Record::AAAA; + } else { + $key = Record::A; + } + for ($i = 1, $l = \count($parts); $i < $l; $i++) { + if ($this->isValidHostName($parts[$i])) { + $data[$key][strtolower($parts[$i])] = $parts[0]; + } + } + } + + yield new CoroutineResult($data); + } + + private function parseCustomServerUri($uri) { + if (!\is_string($uri)) { + throw new ResolutionException( + 'Invalid server address ($uri must be a string IP address, ' . gettype($uri) . " given)" + ); + } + if (strpos($uri, "://") !== false) { + return $uri; + } + if (($colonPos = strrpos(":", $uri)) !== false) { + $addr = \substr($uri, 0, $colonPos); + $port = \substr($uri, $colonPos); + } else { + $addr = $uri; + $port = 53; + } + $addr = trim($addr, "[]"); + if (!$inAddr = @\inet_pton($addr)) { + throw new ResolutionException( + 'Invalid server $uri; string IP address required' + ); + } + + return isset($inAddr[4]) ? "udp://[{$addr}]:{$port}" : "udp://{$addr}:{$port}"; + } + + private function loadExistingServer($uri) { + if (empty($this->serverUriMap[$uri])) { + return null; + } + + $server = $this->serverUriMap[$uri]; + + if (\is_resource($server->socket)) { + unset($this->serverIdTimeoutMap[$server->id]); + \Amp\enable($server->watcherId); + return $server; + } + + $this->unloadServer($server->id); + return null; + } + + private function loadNewServer($uri) { + if (!$socket = @\stream_socket_client($uri, $errno, $errstr)) { + throw new ResolutionException(sprintf( + "Connection to %s failed: [Error #%d] %s", + $uri, + $errno, + $errstr + )); + } + + \stream_set_blocking($socket, false); + $id = (int) $socket; + $server = new \StdClass; + $server->id = $id; + $server->uri = $uri; + $server->socket = $socket; + $server->buffer = ""; + $server->length = INF; + $server->pendingRequests = []; + $server->watcherId = \Amp\onReadable($socket, function($watcherId, $socket) { + $this->onReadable($socket); + }, [ + "enable" => true, + "keep_alive" => true, + ]); + $this->serverIdMap[$id] = $server; + $this->serverUriMap[$uri] = $server; + + return $server; + } + + private function unloadServer($serverId, $error = null) { + // Might already have been unloaded (especially if multiple requests happen) + if (!isset($this->serverIdMap[$serverId])) { + return; + } + + $server = $this->serverIdMap[$serverId]; + \Amp\cancel($server->watcherId); + unset( + $this->serverIdMap[$serverId], + $this->serverUriMap[$server->uri] + ); + if (\is_resource($server->socket)) { + @\fclose($server->socket); + } + if ($error && $server->pendingRequests) { + foreach (array_keys($server->pendingRequests) as $requestId) { + list($promisor) = $this->pendingRequests[$requestId]; + $promisor->fail($error); + } + } + } + + private function onReadable($socket) { + $serverId = (int) $socket; + $packet = @\fread($socket, 512); + if ($packet != "") { + $server = $this->serverIdMap[$serverId]; + if (\substr($server->uri, 0, 6) == "tcp://") { + if ($server->length == INF) { + $server->length = unpack("n", $packet)[1]; + $packet = substr($packet, 2); + } + $server->buffer .= $packet; + while ($server->length <= \strlen($server->buffer)) { + $this->decodeResponsePacket($serverId, substr($server->buffer, 0, $server->length)); + $server->buffer = substr($server->buffer, $server->length); + if (\strlen($server->buffer) >= 2 + $server->length) { + $server->length = unpack("n", $server->buffer)[1]; + $server->buffer = substr($server->buffer, 2); + } else { + $server->length = INF; + } + } + } else { + $this->decodeResponsePacket($serverId, $packet); + } + } else { + $this->unloadServer($serverId, new ResolutionException( + "Server connection failed" + )); + } + } + + private function decodeResponsePacket($serverId, $packet) { + try { + $response = $this->decoder->decode($packet); + $requestId = $response->getID(); + $responseCode = $response->getResponseCode(); + $responseType = $response->getType(); + + if ($responseCode !== 0) { + $this->finalizeResult($serverId, $requestId, new ResolutionException( + "Server returned error code: {$responseCode}" + )); + } elseif ($responseType !== MessageTypes::RESPONSE) { + $this->unloadServer($serverId, new ResolutionException( + "Invalid server reply; expected RESPONSE but received QUERY" + )); + } else { + $this->processDecodedResponse($serverId, $requestId, $response); + } + } catch (\Exception $e) { + $this->unloadServer($serverId, new ResolutionException( + "Response decode error", 0, $e + )); + } + } + + private function processDecodedResponse($serverId, $requestId, $response) { + list($promisor, $name, $type, $uri) = $this->pendingRequests[$requestId]; + + // Retry via tcp if message has been truncated + if ($response->isTruncated()) { + if (\substr($uri, 0, 6) != "tcp://") { + $uri = \preg_replace("#[a-z.]+://#", "tcp://", $uri); + $promisor->succeed($this->doRequest($uri, $name, $type)); + } else { + $this->finalizeResult($serverId, $requestId, new ResolutionException( + "Server returned truncated response" + )); + } + return; + } + + $answers = $response->getAnswerRecords(); + foreach ($answers as $record) { + $result[$record->getType()][] = [(string) $record->getData(), $record->getType(), $record->getTTL()]; + } + if (empty($result)) { + $this->arrayCache->set("$name#$type", [], 300); // "it MUST NOT cache it for longer than five (5) minutes" per RFC 2308 section 7.1 + $this->finalizeResult($serverId, $requestId, new NoRecordException( + "No records returned for {$name}" + )); + } else { + $this->finalizeResult($serverId, $requestId, $error = null, $result); + } + } + + private function finalizeResult($serverId, $requestId, $error = null, $result = null) { + if (empty($this->pendingRequests[$requestId])) { + return; + } + + list($promisor, $name) = $this->pendingRequests[$requestId]; + $server = $this->serverIdMap[$serverId]; + unset( + $this->pendingRequests[$requestId], + $server->pendingRequests[$requestId] + ); + if (empty($server->pendingRequests)) { + $this->serverIdTimeoutMap[$server->id] = $this->now + IDLE_TIMEOUT; + \Amp\disable($server->watcherId); + \Amp\enable($this->serverTimeoutWatcher); + } + if ($error) { + $promisor->fail($error); + } else { + foreach ($result as $type => $records) { + $minttl = INF; + foreach ($records as list( , $ttl)) { + if ($ttl && $minttl > $ttl) { + $minttl = $ttl; + } + } + $this->arrayCache->set("$name#$type", $records, $minttl); + } + $promisor->succeed($result); + } + } +} \ No newline at end of file diff --git a/lib/functions.php b/lib/functions.php index 83e288b..84f1579 100644 --- a/lib/functions.php +++ b/lib/functions.php @@ -2,16 +2,30 @@ namespace Amp\Dns; -use Amp\Cache\ArrayCache; -use Amp\CoroutineResult; -use Amp\Deferred; -use Amp\Failure; -use Amp\Success; -use LibDNS\Decoder\DecoderFactory; -use LibDNS\Encoder\EncoderFactory; -use LibDNS\Messages\MessageFactory; -use LibDNS\Messages\MessageTypes; -use LibDNS\Records\QuestionFactory; +/** + * Retrieve the application-wide dns resolver instance + * + * @param \Amp\Dns\Resolver $assign Optionally specify a new default dns resolver instance + * @return \Amp\Dns\Resolver Returns the application-wide dns resolver instance + */ +function resolver(Resolver $assign = null) { + static $resolver; + if ($assign) { + return ($resolver = $assign); + } elseif ($resolver) { + return $resolver; + } else { + return ($resolver = driver()); + } +} +/** + * Create a new dns resolver best-suited for the current environment + * + * @return \Amp\Dns\Resolver + */ +function driver() { + return new SystemResolver; +} /** * Resolve a hostname name to an IP address @@ -45,18 +59,8 @@ use LibDNS\Records\QuestionFactory; * @TODO add boolean "clear_cache" option flag */ function resolve($name, array $options = []) { - if (!$inAddr = @\inet_pton($name)) { - if (__isValidHostName($name)) { - $types = empty($options["types"]) ? [Record::A, Record::AAAA] : $options["types"]; - return __pipeResult(__recurseWithHosts($name, $types, $options), $types); - } else { - return new Failure(new ResolutionException("Cannot resolve; invalid host name")); - } - } else { - return new Success([[$name, isset($inAddr[4]) ? Record::AAAA : Record::A, $ttl = null]]); - } + return resolver()->resolve($name, $options); } - /** * Query specific DNS records. * @@ -66,548 +70,5 @@ function resolve($name, array $options = []) { * @return \Amp\Promise */ function query($name, $type, array $options = []) { - $handler = __NAMESPACE__ . "\\" . (empty($options["recurse"]) ? "__doResolve" : "__doRecurse"); - $types = (array) $type; - return __pipeResult(\Amp\resolve($handler($name, $types, $options)), $types); -} - -function __isValidHostName($name) { - $pattern = "/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9]){0,1})(?:\\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*$/i"; - - return !isset($name[253]) && \preg_match($pattern, $name); -} - -// flatten $result while preserving order according to $types (append unspecified types for e.g. Record::ALL queries) -function __pipeResult($promise, array $types) { - return \Amp\pipe($promise, function (array $result) use ($types) { - $retval = []; - foreach ($types as $type) { - if (isset($result[$type])) { - $retval = \array_merge($retval, $result[$type]); - unset($result[$type]); - } - } - return $result ? \array_merge($retval, \call_user_func_array("array_merge", $result)) : $retval; - }); -} - -function __recurseWithHosts($name, array $types, $options) { - // Check for hosts file matches - if (!isset($options["hosts"]) || $options["hosts"]) { - static $hosts = null; - if ($hosts === null || !empty($options["reload_hosts"])) { - return \Amp\pipe(\Amp\resolve(__loadHostsFile()), function ($value) use (&$hosts, $name, $types, $options) { - unset($options["reload_hosts"]); // avoid recursion - $hosts = $value; - return __recurseWithHosts($name, $types, $options); - }); - } - $result = []; - if (in_array(Record::A, $types) && isset($hosts[Record::A][$name])) { - $result[Record::A] = [[$hosts[Record::A][$name], Record::A, $ttl = null]]; - } - if (in_array(Record::AAAA, $types) && isset($hosts[Record::AAAA][$name])) { - $result[Record::AAAA] = [[$hosts[Record::AAAA][$name], Record::AAAA, $ttl = null]]; - } - if ($result) { - return new Success($result); - } - } - - return \Amp\resolve(__doRecurse($name, $types, $options)); -} - -function __doRecurse($name, array $types, $options) { - if (array_intersect($types, [Record::CNAME, Record::DNAME])) { - throw new ResolutionException("Cannot use recursion for CNAME and DNAME records"); - } - - $types = array_merge($types, [Record::CNAME, Record::DNAME]); - $lookupName = $name; - for ($i = 0; $i < 30; $i++) { - $result = (yield \Amp\resolve(__doResolve($lookupName, $types, $options))); - if (count($result) > isset($result[Record::CNAME]) + isset($result[Record::DNAME])) { - unset($result[Record::CNAME], $result[Record::DNAME]); - yield new CoroutineResult($result); - return; - } - // @TODO check for potentially using recursion and iterate over *all* CNAME/DNAME - // @FIXME check higher level for CNAME? - foreach ([Record::CNAME, Record::DNAME] as $type) { - if (isset($result[$type])) { - list($lookupName) = $result[$type][0]; - } - } - } - - throw new ResolutionException("CNAME or DNAME chain too long (possible recursion?)"); -} - -function __doRequest($state, $uri, $name, $type) { - $server = __loadExistingServer($state, $uri) ?: __loadNewServer($state, $uri); - - // Get the next available request ID - do { - $requestId = $state->requestIdCounter++; - if ($state->requestIdCounter >= MAX_REQUEST_ID) { - $state->requestIdCounter = 1; - } - } while (isset($state->pendingRequests[$requestId])); - - // Create question record - $question = $state->questionFactory->create($type); - $question->setName($name); - - // Create request message - $request = $state->messageFactory->create(MessageTypes::QUERY); - $request->getQuestionRecords()->add($question); - $request->isRecursionDesired(true); - $request->setID($requestId); - - // Encode request message - $requestPacket = $state->encoder->encode($request); - - if (substr($uri, 0, 6) == "tcp://") { - $requestPacket = pack("n", strlen($requestPacket)) . $requestPacket; - } - - // Send request - $bytesWritten = \fwrite($server->socket, $requestPacket); - if ($bytesWritten === false || isset($packet[$bytesWritten])) { - throw new ResolutionException( - "Request send failed" - ); - } - - $promisor = new Deferred; - $server->pendingRequests[$requestId] = true; - $state->pendingRequests[$requestId] = [$promisor, $name, $type, $uri]; - - return $promisor->promise(); -} - -function __doResolve($name, array $types, $options) { - static $state; - $state = $state ?: (yield \Amp\resolve(__init())); - - if (empty($types)) { - yield new CoroutineResult([]); - return; - } - - assert(array_reduce($types, function ($result, $val) { return $result && \is_int($val); }, true), 'The $types passed to DNS functions must all be integers (from \Amp\Dns\Record class)'); - - $name = \strtolower($name); - $result = []; - - // Check for cache hits - if (!isset($options["cache"]) || $options["cache"]) { - foreach ($types as $k => $type) { - $cacheKey = "$name#$type"; - $cacheValue = (yield $state->arrayCache->get($cacheKey)); - - if ($cacheValue !== null) { - $result[$type] = $cacheValue; - unset($types[$k]); - } - } - if (empty($types)) { - if (empty(array_filter($result))) { - throw new NoRecordException("No records returned for {$name} (cached result)"); - } else { - yield new CoroutineResult($result); - return; - } - } - } - - $timeout = empty($options["timeout"]) ? $state->config["timeout"] : (int) $options["timeout"]; - - if (empty($options["server"])) { - if (empty($state->config["nameservers"])) { - throw new ResolutionException("No nameserver specified in system config"); - } - - $uri = "udp://" . $state->config["nameservers"][0]; - } else { - $uri = __parseCustomServerUri($options["server"]); - } - - foreach ($types as $type) { - $promises[] = __doRequest($state, $uri, $name, $type); - } - - try { - list( , $resultArr) = (yield \Amp\timeout(\Amp\some($promises), $timeout)); - foreach ($resultArr as $value) { - $result += $value; - } - } catch (\Amp\TimeoutException $e) { - if (substr($uri, 0, 6) == "tcp://") { - throw new TimeoutException( - "Name resolution timed out for {$name}" - ); - } else { - $options["server"] = \preg_replace("#[a-z.]+://#", "tcp://", $uri); - yield new CoroutineResult(\Amp\resolve(__doResolve($name, $types, $options))); - return; - } - } catch (ResolutionException $e) { - if (empty($result)) { // if we have no cached results - throw $e; - } - } catch (\Amp\CombinatorException $e) { // if all promises in Amp\some fail - if (empty($result)) { // if we have no cached results - foreach ($e->getExceptions() as $ex) { - if ($ex instanceof NoRecordException) { - throw new NoRecordException("No records returned for {$name}", 0, $e); - } - } - throw new ResolutionException("All name resolution requests failed", 0, $e); - } - } - - yield new CoroutineResult($result); -} - -function __init() { - $state = new \StdClass; - $state->messageFactory = new MessageFactory; - $state->questionFactory = new QuestionFactory; - $state->encoder = (new EncoderFactory)->create(); - $state->decoder = (new DecoderFactory)->create(); - $state->arrayCache = new ArrayCache; - $state->requestIdCounter = 1; - $state->pendingRequests = []; - $state->serverIdMap = []; - $state->serverUriMap = []; - $state->serverIdTimeoutMap = []; - $state->now = \time(); - $state->serverTimeoutWatcher = \Amp\repeat(function ($watcherId) use ($state) { - $state->now = $now = \time(); - foreach ($state->serverIdTimeoutMap as $id => $expiry) { - if ($now > $expiry) { - __unloadServer($state, $id); - } - } - if (empty($state->serverIdMap)) { - \Amp\disable($watcherId); - } - }, 1000, $options = [ - "enable" => true, - "keep_alive" => false, - ]); - - $state->config = (yield \Amp\resolve(__loadResolvConf())); - - yield new CoroutineResult($state); -} - -/** @link http://man7.org/linux/man-pages/man5/resolv.conf.5.html */ -function __loadResolvConf($path = null) { - $result = [ - "nameservers" => [ - "8.8.8.8:53", - "8.8.4.4:53", - ], - "timeout" => 3000, - "attempts" => 2, - ]; - - if (\stripos(PHP_OS, "win") !== 0) { - $path = $path ?: "/etc/resolv.conf"; - - try { - $lines = explode("\n", (yield \Amp\File\get($path))); - $result["nameservers"] = []; - - foreach ($lines as $line) { - $line = \preg_split('#\s+#', $line, 2); - if (\count($line) !== 2) { - continue; - } - - list($type, $value) = $line; - if ($type === "nameserver") { - $line[1] = trim($line[1]); - $ip = @\inet_pton($line[1]); - - if ($ip === false) { - continue; - } - - if (isset($ip[15])) { - $result["nameservers"][] = "[" . $line[1] . "]:53"; - } else { - $result["nameservers"][] = $line[1] . ":53"; - } - } elseif ($type === "options") { - $optline = preg_split('#\s+#', $value, 2); - if (\count($optline) !== 2) { - continue; - } - - // TODO: Respect the contents of the attempts setting during resolution - - list($option, $value) = $optline; - if (in_array($option, ["timeout", "attempts"])) { - $result[$option] = (int) $value; - } - } - } - } catch (\Amp\File\FilesystemException $e) {} - } - - yield new CoroutineResult($result); -} - -function __loadHostsFile($path = null) { - $data = []; - if (empty($path)) { - $path = \stripos(PHP_OS, "win") === 0 - ? 'C:\Windows\system32\drivers\etc\hosts' - : '/etc/hosts'; - } - try { - $contents = (yield \Amp\File\get($path)); - } catch (\Exception $e) { - yield new CoroutineResult($data); - - return; - } - $lines = \array_filter(\array_map("trim", \explode("\n", $contents))); - foreach ($lines as $line) { - if ($line[0] === "#") { - continue; - } - $parts = \preg_split('/\s+/', $line); - if (!($ip = @\inet_pton($parts[0]))) { - continue; - } elseif (isset($ip[4])) { - $key = Record::AAAA; - } else { - $key = Record::A; - } - for ($i = 1, $l = \count($parts); $i < $l; $i++) { - if (__isValidHostName($parts[$i])) { - $data[$key][strtolower($parts[$i])] = $parts[0]; - } - } - } - - yield new CoroutineResult($data); -} - -function __parseCustomServerUri($uri) { - if (!\is_string($uri)) { - throw new ResolutionException( - 'Invalid server address ($uri must be a string IP address, ' . gettype($uri) . " given)" - ); - } - if (strpos($uri, "://") !== false) { - return $uri; - } - if (($colonPos = strrpos(":", $uri)) !== false) { - $addr = \substr($uri, 0, $colonPos); - $port = \substr($uri, $colonPos); - } else { - $addr = $uri; - $port = 53; - } - $addr = trim($addr, "[]"); - if (!$inAddr = @\inet_pton($addr)) { - throw new ResolutionException( - 'Invalid server $uri; string IP address required' - ); - } - - return isset($inAddr[4]) ? "udp://[{$addr}]:{$port}" : "udp://{$addr}:{$port}"; -} - -function __loadExistingServer($state, $uri) { - if (empty($state->serverUriMap[$uri])) { - return null; - } - - $server = $state->serverUriMap[$uri]; - - if (\is_resource($server->socket)) { - unset($state->serverIdTimeoutMap[$server->id]); - \Amp\enable($server->watcherId); - return $server; - } - - __unloadServer($state, $server->id); - return null; -} - -function __loadNewServer($state, $uri) { - if (!$socket = @\stream_socket_client($uri, $errno, $errstr)) { - throw new ResolutionException(sprintf( - "Connection to %s failed: [Error #%d] %s", - $uri, - $errno, - $errstr - )); - } - - \stream_set_blocking($socket, false); - $id = (int) $socket; - $server = new \StdClass; - $server->id = $id; - $server->uri = $uri; - $server->socket = $socket; - $server->buffer = ""; - $server->length = INF; - $server->pendingRequests = []; - $server->watcherId = \Amp\onReadable($socket, 'Amp\Dns\__onReadable', [ - "enable" => true, - "keep_alive" => true, - "cb_data" => $state, - ]); - $state->serverIdMap[$id] = $server; - $state->serverUriMap[$uri] = $server; - - return $server; -} - -function __unloadServer($state, $serverId, $error = null) { - // Might already have been unloaded (especially if multiple requests happen) - if (!isset($state->serverIdMap[$serverId])) { - return; - } - - $server = $state->serverIdMap[$serverId]; - \Amp\cancel($server->watcherId); - unset( - $state->serverIdMap[$serverId], - $state->serverUriMap[$server->uri] - ); - if (\is_resource($server->socket)) { - @\fclose($server->socket); - } - if ($error && $server->pendingRequests) { - foreach (array_keys($server->pendingRequests) as $requestId) { - list($promisor) = $state->pendingRequests[$requestId]; - $promisor->fail($error); - } - } -} - -function __onReadable($watcherId, $socket, $state) { - $serverId = (int) $socket; - $packet = @\fread($socket, 512); - if ($packet != "") { - $server = $state->serverIdMap[$serverId]; - if (\substr($server->uri, 0, 6) == "tcp://") { - if ($server->length == INF) { - $server->length = unpack("n", $packet)[1]; - $packet = substr($packet, 2); - } - $server->buffer .= $packet; - while ($server->length <= \strlen($server->buffer)) { - __decodeResponsePacket($state, $serverId, substr($server->buffer, 0, $server->length)); - $server->buffer = substr($server->buffer, $server->length); - if (\strlen($server->buffer) >= 2 + $server->length) { - $server->length = unpack("n", $server->buffer)[1]; - $server->buffer = substr($server->buffer, 2); - } else { - $server->length = INF; - } - } - } else { - __decodeResponsePacket($state, $serverId, $packet); - } - } else { - __unloadServer($state, $serverId, new ResolutionException( - "Server connection failed" - )); - } -} - -function __decodeResponsePacket($state, $serverId, $packet) { - try { - $response = $state->decoder->decode($packet); - $requestId = $response->getID(); - $responseCode = $response->getResponseCode(); - $responseType = $response->getType(); - - if ($responseCode !== 0) { - __finalizeResult($state, $serverId, $requestId, new ResolutionException( - "Server returned error code: {$responseCode}" - )); - } elseif ($responseType !== MessageTypes::RESPONSE) { - __unloadServer($state, $serverId, new ResolutionException( - "Invalid server reply; expected RESPONSE but received QUERY" - )); - } else { - __processDecodedResponse($state, $serverId, $requestId, $response); - } - } catch (\Exception $e) { - __unloadServer($state, $serverId, new ResolutionException( - "Response decode error", 0, $e - )); - } -} - -function __processDecodedResponse($state, $serverId, $requestId, $response) { - list($promisor, $name, $type, $uri) = $state->pendingRequests[$requestId]; - - // Retry via tcp if message has been truncated - if ($response->isTruncated()) { - if (\substr($uri, 0, 6) != "tcp://") { - $uri = \preg_replace("#[a-z.]+://#", "tcp://", $uri); - $promisor->succeed(__doRequest($state, $uri, $name, $type)); - } else { - __finalizeResult($state, $serverId, $requestId, new ResolutionException( - "Server returned truncated response" - )); - } - return; - } - - $answers = $response->getAnswerRecords(); - foreach ($answers as $record) { - $result[$record->getType()][] = [(string) $record->getData(), $record->getType(), $record->getTTL()]; - } - if (empty($result)) { - $state->arrayCache->set("$name#$type", [], 300); // "it MUST NOT cache it for longer than five (5) minutes" per RFC 2308 section 7.1 - __finalizeResult($state, $serverId, $requestId, new NoRecordException( - "No records returned for {$name}" - )); - } else { - __finalizeResult($state, $serverId, $requestId, $error = null, $result); - } -} - -function __finalizeResult($state, $serverId, $requestId, $error = null, $result = null) { - if (empty($state->pendingRequests[$requestId])) { - return; - } - - list($promisor, $name) = $state->pendingRequests[$requestId]; - $server = $state->serverIdMap[$serverId]; - unset( - $state->pendingRequests[$requestId], - $server->pendingRequests[$requestId] - ); - if (empty($server->pendingRequests)) { - $state->serverIdTimeoutMap[$server->id] = $state->now + IDLE_TIMEOUT; - \Amp\disable($server->watcherId); - \Amp\enable($state->serverTimeoutWatcher); - } - if ($error) { - $promisor->fail($error); - } else { - foreach ($result as $type => $records) { - $minttl = INF; - foreach ($records as list( , $ttl)) { - if ($ttl && $minttl > $ttl) { - $minttl = $ttl; - } - } - $state->arrayCache->set("$name#$type", $records, $minttl); - } - $promisor->succeed($result); - } -} + return resolver()->query($name, $type, $options); +} \ No newline at end of file diff --git a/test/ResolvConfTest.php b/test/ResolvConfTest.php index e9c8e15..cf5df7d 100644 --- a/test/ResolvConfTest.php +++ b/test/ResolvConfTest.php @@ -2,9 +2,15 @@ namespace Amp\Dns\Test; +use ReflectionObject; + class ResolvConfTest extends \PHPUnit_Framework_TestCase { public function test() { - $result = \Amp\wait(\Amp\resolve(\Amp\Dns\__loadResolvConf(__DIR__ . "/data/resolv.conf"))); + $reflector = new ReflectionObject(\Amp\Dns\resolver()); + $method = $reflector->getMethod("loadResolvConf"); + $method->setAccessible(true); + + $result = \Amp\wait(\Amp\resolve($method->invoke(\Amp\Dns\resolver(), __DIR__ . "/data/resolv.conf"))); $this->assertSame([ "nameservers" => [ @@ -17,7 +23,11 @@ class ResolvConfTest extends \PHPUnit_Framework_TestCase { } public function testDefaultsOnConfNotFound() { - $result = \Amp\wait(\Amp\resolve(\Amp\Dns\__loadResolvConf(__DIR__ . "/data/invalid.conf"))); + $reflector = new ReflectionObject(\Amp\Dns\resolver()); + $method = $reflector->getMethod("loadResolvConf"); + $method->setAccessible(true); + + $result = \Amp\wait(\Amp\resolve($method->invoke(\Amp\Dns\resolver(), __DIR__ . "/data/invalid.conf"))); $this->assertSame([ "nameservers" => [