diff --git a/src/Config/Config.php b/src/Config/Config.php index 0b19e9af..d30b8f50 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -97,6 +97,40 @@ public static function loadResolvConfBlocking($path = null) } } + $matches = []; + preg_match_all('/^search.*\s*$/m', $contents, $matches); + if (count($matches) > 0 && count($matches[0]) > 0 && isset($matches[0][count($matches[0]) - 1])) { + $searches = preg_split('/\s+/', trim($matches[0][count($matches[0]) - 1])); + unset($searches[0]); + $config->search = array_values($searches); + } + + $matches = []; + preg_match_all('/^options.*\s*$/m', $contents, $matches); + if (isset($matches[0][0])) { + $options = preg_split('/\s+/', trim($matches[0][0])); + array_shift($options); + + foreach ($options as $option) { + $value = null; + if (strpos($option, ':') !== false) { + [$option, $value] = explode(':', $option, 2); + } + + switch ($option) { + case 'ndots': + $config->options->ndots = ((int) $value) > 15 ? 15 : (int) $value; + break; + case 'attempts': + $config->options->attempts = ((int) $value) > 5 ? 5 : (int) $value; + break; + case 'timeout': + $config->options->timeout = ((int) $value) > 30 ? 30 : (int) $value; + break; + } + } + } + return $config; } @@ -134,4 +168,16 @@ public static function loadWmicBlocking($command = null) } public $nameservers = []; + /** + * @var array + */ + public $search = []; + /** + * @var Options + */ + public $options; + + public function __construct() { + $this->options = new Options(); + } } diff --git a/src/Config/Options.php b/src/Config/Options.php new file mode 100644 index 00000000..080a7d02 --- /dev/null +++ b/src/Config/Options.php @@ -0,0 +1,21 @@ + + */ + public $ndots = 1; + /** + * @var int<1, 5> + */ + public $attempts = 2; + /** + * @var int<1, 30> + */ + public $timeout = 5; +} diff --git a/src/Query/SearchingExecutor.php b/src/Query/SearchingExecutor.php new file mode 100644 index 00000000..b8d3998f --- /dev/null +++ b/src/Query/SearchingExecutor.php @@ -0,0 +1,132 @@ + + */ + private $domains; + + public function __construct(ExecutorInterface $base, int $ndots, string $firstDomain, string ...$domains) + { + $this->executor = $base; + $this->ndots = $ndots; + + array_unshift($domains, $firstDomain); + $this->domains = $domains; + } + + public function query(Query $query) + { + if (substr($query->name, -1) === '.') { + return $this->executor->query(new Query( + substr($query->name, 0, -1), + $query->type, + $query->class + )); + } + + $startWithAbsolute = substr_count($query->name, '.') >= $this->ndots; + $domains = []; + if ($startWithAbsolute === true) { + $domains[] = $query->name; + } + foreach ($this->domains as $domain) { + $domains[] = $query->name . '.' . $domain; + } + if ($startWithAbsolute === false) { + $domains[] = $query->name; + } + + $firstDomain = array_shift($domains); + $seeker = function (Message $message) use ($query, &$seeker, &$domains): PromiseInterface { + if ($this->hasRecords($message, $query)) { + return resolve($message); + } + + + $promise = $this->executor->query(new Query( + array_shift($domains), + $query->type, + $query->class + )); + + if (count($domains) > 0) { + $promise = $promise->then($seeker); + } + + return $promise; + }; + + return $this->executor->query(new Query( + $firstDomain, + $query->type, + $query->class + ))->then($seeker); + } + + private function hasRecords(Message $message, Query $query): bool + { + foreach ($message->answers as $record) { + if ($record->type === $query->type && $record->class === $query->class) { + return true; + } + } + + return false; + } +} diff --git a/src/Resolver/Factory.php b/src/Resolver/Factory.php index eedebe1a..5713f601 100644 --- a/src/Resolver/Factory.php +++ b/src/Resolver/Factory.php @@ -6,12 +6,14 @@ use React\Cache\CacheInterface; use React\Dns\Config\Config; use React\Dns\Config\HostsFile; +use React\Dns\Config\Options; use React\Dns\Query\CachingExecutor; use React\Dns\Query\CoopExecutor; use React\Dns\Query\ExecutorInterface; use React\Dns\Query\FallbackExecutor; use React\Dns\Query\HostsFileExecutor; use React\Dns\Query\RetryExecutor; +use React\Dns\Query\SearchingExecutor; use React\Dns\Query\SelectiveTransportExecutor; use React\Dns\Query\TcpTransportExecutor; use React\Dns\Query\TimeoutExecutor; @@ -125,26 +127,24 @@ private function createExecutor($nameserver, LoopInterface $loop) if ($tertiary !== false) { // 3 DNS servers given => nest first with fallback for second and third - return new CoopExecutor( - new RetryExecutor( + return $this->createTopLevelDecoratingExectors( + new FallbackExecutor( + $this->createSingleExecutor($primary, $nameserver->options, $loop), new FallbackExecutor( - $this->createSingleExecutor($primary, $loop), - new FallbackExecutor( - $this->createSingleExecutor($secondary, $loop), - $this->createSingleExecutor($tertiary, $loop) - ) + $this->createSingleExecutor($secondary, $nameserver->options, $loop), + $this->createSingleExecutor($tertiary, $nameserver->options, $loop) ) - ) + ), + $nameserver ); } elseif ($secondary !== false) { // 2 DNS servers given => fallback from first to second - return new CoopExecutor( - new RetryExecutor( - new FallbackExecutor( - $this->createSingleExecutor($primary, $loop), - $this->createSingleExecutor($secondary, $loop) - ) - ) + return $this->createTopLevelDecoratingExectors( + new FallbackExecutor( + $this->createSingleExecutor($primary, $nameserver->options, $loop), + $this->createSingleExecutor($secondary, $nameserver->options, $loop) + ), + $nameserver ); } else { // 1 DNS server given => use single executor @@ -152,27 +152,38 @@ private function createExecutor($nameserver, LoopInterface $loop) } } - return new CoopExecutor(new RetryExecutor($this->createSingleExecutor($nameserver, $loop))); + return $this->createTopLevelDecoratingExectors($this->createSingleExecutor($nameserver, new Options(), $loop), $nameserver); + } + + private function createTopLevelDecoratingExectors(ExecutorInterface $executor, $nameserver) + { + $executor = new RetryExecutor($executor, (is_string($nameserver) ? new Options() : $nameserver->options)->attempts); + if ($nameserver instanceof Config && count($nameserver->search) > 0) { + $executor = new SearchingExecutor($executor, $nameserver->options->ndots, ...$nameserver->search); + } + + return new CoopExecutor($executor); } /** * @param string $nameserver + * @param Options $options * @param LoopInterface $loop * @return ExecutorInterface * @throws \InvalidArgumentException for invalid DNS server address */ - private function createSingleExecutor($nameserver, LoopInterface $loop) + private function createSingleExecutor($nameserver, Options $options, LoopInterface $loop) { $parts = \parse_url($nameserver); if (isset($parts['scheme']) && $parts['scheme'] === 'tcp') { - $executor = $this->createTcpExecutor($nameserver, $loop); + $executor = $this->createTcpExecutor($nameserver, $options->timeout, $loop); } elseif (isset($parts['scheme']) && $parts['scheme'] === 'udp') { - $executor = $this->createUdpExecutor($nameserver, $loop); + $executor = $this->createUdpExecutor($nameserver, $options->timeout, $loop); } else { $executor = new SelectiveTransportExecutor( - $this->createUdpExecutor($nameserver, $loop), - $this->createTcpExecutor($nameserver, $loop) + $this->createUdpExecutor($nameserver, $options->timeout, $loop), + $this->createTcpExecutor($nameserver, $options->timeout, $loop) ); } @@ -181,33 +192,35 @@ private function createSingleExecutor($nameserver, LoopInterface $loop) /** * @param string $nameserver + * @param int $timeout * @param LoopInterface $loop * @return TimeoutExecutor * @throws \InvalidArgumentException for invalid DNS server address */ - private function createTcpExecutor($nameserver, LoopInterface $loop) + private function createTcpExecutor($nameserver, int $timeout, LoopInterface $loop) { return new TimeoutExecutor( new TcpTransportExecutor($nameserver, $loop), - 5.0, + $timeout, $loop ); } /** * @param string $nameserver + * @param int $timeout * @param LoopInterface $loop * @return TimeoutExecutor * @throws \InvalidArgumentException for invalid DNS server address */ - private function createUdpExecutor($nameserver, LoopInterface $loop) + private function createUdpExecutor($nameserver, int $timeout, LoopInterface $loop) { return new TimeoutExecutor( new UdpTransportExecutor( $nameserver, $loop ), - 5.0, + $timeout, $loop ); } diff --git a/tests/Config/ConfigTest.php b/tests/Config/ConfigTest.php index 46d2f53d..170a4d11 100644 --- a/tests/Config/ConfigTest.php +++ b/tests/Config/ConfigTest.php @@ -30,6 +30,10 @@ public function testLoadsFromExplicitPath() $config = Config::loadResolvConfBlocking(__DIR__ . '/../Fixtures/etc/resolv.conf'); $this->assertEquals(['8.8.8.8'], $config->nameservers); + $this->assertEquals(['svc', 'svc.cluster', 'svc.cluster.local'], $config->search); + $this->assertEquals(2, $config->options->ndots); + $this->assertEquals(4, $config->options->attempts); + $this->assertEquals(29, $config->options->timeout); } public function testLoadThrowsWhenPathIsInvalid() diff --git a/tests/Fixtures/etc/resolv.conf b/tests/Fixtures/etc/resolv.conf index cae093a8..31fb1e9f 100644 --- a/tests/Fixtures/etc/resolv.conf +++ b/tests/Fixtures/etc/resolv.conf @@ -1 +1,5 @@ nameserver 8.8.8.8 +search svc.cluster.local svc.cluster svc +options timeout:29 no-reload ndots:2 trust-ad attempts:4 +# Any search directives before this one MUST be ignored +search svc svc.cluster svc.cluster.local diff --git a/tests/Query/SearchingExecutorTest.php b/tests/Query/SearchingExecutorTest.php new file mode 100644 index 00000000..3aa6f70e --- /dev/null +++ b/tests/Query/SearchingExecutorTest.php @@ -0,0 +1,191 @@ +createMock(ExecutorInterface::class); + $executor->expects($this->once())->method('query')->with($normalizedQuery)->willReturn(resolve(new Message())); + + $seeker = new SearchingExecutor($executor, 5, 'svc'); + + $promise = $seeker->query($fqdnQuery); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then($this->expectCallableOnceWith($this->isInstanceOf(Message::class)), $this->expectCallableNever()); + } + + public function testQueryWillAttemptToSearchAndReturnOnFirstResponseWithTypeAndCLassMatchingRecords() + { + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $searchInSvcQuery = new Query('reactphp.org.svc', Message::TYPE_A, Message::CLASS_IN); + + $message = new Message(); + $message->answers[] = new Record($searchInSvcQuery->name, $searchInSvcQuery->type, $searchInSvcQuery->class, 13, '127.0.0.1'); + + $executor = $this->createMock(ExecutorInterface::class); + $executor->expects($this->once())->method('query')->with($searchInSvcQuery)->willReturn(resolve($message)); + + $seeker = new SearchingExecutor($executor, 5, 'svc'); + + $promise = $seeker->query($query); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then($this->expectCallableOnceWith($this->identicalTo($message)), $this->expectCallableNever()); + } + + public function testQueryWillAttemptToSearchAndReturnOnTheSecondResponseWithTypeAndCLassMatchingRecordsBecauseTheFirstIsADifferentType() + { + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $searchInSvcQuery = new Query('reactphp.org.svc', Message::TYPE_A, Message::CLASS_IN); + $searchInSvcClusterQuery = new Query('reactphp.org.svc.cluster', Message::TYPE_A, Message::CLASS_IN); + + $messageSvc = new Message(); + $messageSvc->answers[] = new Record($searchInSvcQuery->name, Message::TYPE_AAAA, $searchInSvcQuery->class, 13, '::1'); + + $messageSvcCluster = new Message(); + $messageSvcCluster->answers[] = new Record($searchInSvcClusterQuery->name, $searchInSvcClusterQuery->type, $searchInSvcClusterQuery->class, 13, '127.0.0.1'); + + $executor = $this->createMock(ExecutorInterface::class); + $executor->expects($this->at(0))->method('query')->with($this->equalTo($searchInSvcQuery))->willReturn(resolve($messageSvc)); + $executor->expects($this->at(1))->method('query')->with($this->equalTo($searchInSvcClusterQuery))->willReturn(resolve($messageSvcCluster)); + + $seeker = new SearchingExecutor($executor, 5, 'svc', 'svc.cluster'); + + $promise = $seeker->query($query); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then($this->expectCallableOnceWith($this->identicalTo($messageSvcCluster)), $this->expectCallableNever()); + } + + public function testQueryWillAttemptToSearchAndReturnOnTheThirdResponseWithTypeAndCLassMatchingRecordsBecauseTheFirstIsADifferentTypeAndTheSecondIsEmpty() + { + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $searchInSvcQuery = new Query('reactphp.org.svc', Message::TYPE_A, Message::CLASS_IN); + $searchInSvcClusterQuery = new Query('reactphp.org.svc.cluster', Message::TYPE_A, Message::CLASS_IN); + $searchInSvcClusterLocalQuery = new Query('reactphp.org.svc.cluster.local', Message::TYPE_A, Message::CLASS_IN); + + $messageSvc = new Message(); + $messageSvc->answers[] = new Record($searchInSvcQuery->name, Message::TYPE_AAAA, $searchInSvcQuery->class, 13, '::1'); + + $messageSvcCluster = new Message(); + + $messageSvcClusterLocal = new Message(); + $messageSvcClusterLocal->answers[] = new Record($searchInSvcClusterLocalQuery->name, $searchInSvcClusterLocalQuery->type, $searchInSvcClusterLocalQuery->class, 13, '127.0.0.1'); + + $executor = $this->createMock(ExecutorInterface::class); + $executor->expects($this->at(0))->method('query')->with($this->equalTo($searchInSvcQuery))->willReturn(resolve($messageSvc)); + $executor->expects($this->at(1))->method('query')->with($this->equalTo($searchInSvcClusterQuery))->willReturn(resolve($messageSvcCluster)); + $executor->expects($this->at(2))->method('query')->with($this->equalTo($searchInSvcClusterLocalQuery))->willReturn(resolve($messageSvcClusterLocal)); + + $seeker = new SearchingExecutor($executor, 5, 'svc', 'svc.cluster', 'svc.cluster.local'); + + $promise = $seeker->query($query); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then($this->expectCallableOnceWith($this->identicalTo($messageSvcClusterLocal)), $this->expectCallableNever()); + } + + public function testQueryWillAttemptToSearchAndReturnOnTheOrignalDomainAsTheSearchesDidntMatchAnyRecordsUpstream() + { + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $searchInSvcQuery = new Query('reactphp.org.svc', Message::TYPE_A, Message::CLASS_IN); + $searchInSvcClusterQuery = new Query('reactphp.org.svc.cluster', Message::TYPE_A, Message::CLASS_IN); + $searchInSvcClusterLocalQuery = new Query('reactphp.org.svc.cluster.local', Message::TYPE_A, Message::CLASS_IN); + + $message = new Message(); + $message->answers[] = new Record($query->name, $query->type, $query->class, 13, '::1'); + + $messageSvc = new Message(); + $messageSvc->answers[] = new Record($searchInSvcQuery->name, Message::TYPE_AAAA, $searchInSvcQuery->class, 13, '::1'); + + $messageSvcCluster = new Message(); + + $messageSvcClusterLocal = new Message(); + + $executor = $this->createMock(ExecutorInterface::class); + $executor->expects($this->at(0))->method('query')->with($this->equalTo($searchInSvcQuery))->willReturn(resolve($messageSvc)); + $executor->expects($this->at(1))->method('query')->with($this->equalTo($searchInSvcClusterQuery))->willReturn(resolve($messageSvcCluster)); + $executor->expects($this->at(2))->method('query')->with($this->equalTo($searchInSvcClusterLocalQuery))->willReturn(resolve($messageSvcClusterLocal)); + $executor->expects($this->at(3))->method('query')->with($this->equalTo($query))->willReturn(resolve($message)); + + $seeker = new SearchingExecutor($executor, 5, 'svc', 'svc.cluster', 'svc.cluster.local'); + + $promise = $seeker->query($query); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then($this->expectCallableOnceWith($this->identicalTo($message)), $this->expectCallableNever()); + } + + public function testQueryWillAttemptToSearchAndReturnOnTheFirstResponseAndNeverTriesToQueryTheOtherDomainsOnTheList() + { + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $searchInSvcQuery = new Query('reactphp.org.svc', Message::TYPE_A, Message::CLASS_IN); + + $messageSvc = new Message(); + $messageSvc->answers[] = new Record($searchInSvcQuery->name, $searchInSvcQuery->type, $searchInSvcQuery->class, 13, '::1'); + + $executor = $this->createMock(ExecutorInterface::class); + $executor->expects($this->once())->method('query')->with($this->equalTo($searchInSvcQuery))->willReturn(resolve($messageSvc)); + + $seeker = new SearchingExecutor($executor, 5, 'svc', 'svc.cluster', 'svc.cluster.local'); + + $promise = $seeker->query($query); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then($this->expectCallableOnceWith($this->identicalTo($messageSvc)), $this->expectCallableNever()); + } + + public function testWhenDotsInQueryEqualThenMakeAbsoluteQuery() + { + $query = new Query('www.reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $message = new Message(); + $message->answers[] = new Record($query->name, $query->type, $query->class, 13, '::1'); + + $executor = $this->createMock(ExecutorInterface::class); + $executor->expects($this->once())->method('query')->with($query)->willReturn(resolve($message)); + + $seeker = new SearchingExecutor($executor, 2, 'svc'); + + $promise = $seeker->query($query); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then($this->expectCallableOnceWith($this->isInstanceOf(Message::class)), $this->expectCallableNever()); + } + + public function testWhenDotsInQueryGreaterThanThenMakeAbsoluteQuery() + { + $query = new Query('www.reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $message = new Message(); + $message->answers[] = new Record($query->name, $query->type, $query->class, 13, '::1'); + + $executor = $this->createMock(ExecutorInterface::class); + $executor->expects($this->once())->method('query')->with($query)->willReturn(resolve($message)); + + $seeker = new SearchingExecutor($executor, 1, 'svc'); + + $promise = $seeker->query($query); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then($this->expectCallableOnceWith($this->isInstanceOf(Message::class)), $this->expectCallableNever()); + } +}