diff --git a/README.md b/README.md index 97d90749..55184ff6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ easily be used to create a DNS server. * [Caching](#caching) * [Custom cache adapter](#custom-cache-adapter) * [Advanced usage](#advanced-usage) + * [HostsFileExecutor](#hostsfileexecutor) * [Install](#install) * [Tests](#tests) * [License](#license) @@ -39,6 +40,11 @@ $loop->run(); See also the [first example](examples). +> Note that the factory loads the hosts file from the filesystem once when + creating the resolver instance. + Ideally, this method should thus be executed only once before the loop starts + and not repeatedly while it is running. + Pending DNS queries can be cancelled by cancelling its pending promise like so: ```php @@ -118,6 +124,24 @@ $loop->run(); See also the [fourth example](examples). +### HostsFileExecutor + +Note that the above `Executor` class always performs an actual DNS query. +If you also want to take entries from your hosts file into account, you may +use this code: + +```php +$hosts = \React\Dns\Config\HostsFile::loadFromPathBlocking(); + +$executor = new Executor($loop, new Parser(), new BinaryDumper(), null); +$executor = new HostsFileExecutor($hosts, $executor); + +$executor->query( + '8.8.8.8:53', + new Query('localhost', Message::TYPE_A, Message::CLASS_IN, time()) +); +``` + ## Install The recommended way to install this library is [through Composer](http://getcomposer.org). diff --git a/src/Config/HostsFile.php b/src/Config/HostsFile.php new file mode 100644 index 00000000..699071ad --- /dev/null +++ b/src/Config/HostsFile.php @@ -0,0 +1,146 @@ +contents = $contents; + } + + /** + * Returns all IPs for the given hostname + * + * @param string $name + * @return string[] + */ + public function getIpsForHost($name) + { + $name = strtolower($name); + + $ips = array(); + foreach (preg_split('/\r?\n/', $this->contents) as $line) { + $parts = preg_split('/\s+/', $line); + $ip = array_shift($parts); + if ($parts && array_search($name, $parts) !== false) { + // remove IPv6 zone ID (`fe80::1%lo0` => `fe80:1`) + if (strpos($ip, ':') !== false && ($pos = strpos($ip, '%')) !== false) { + $ip= substr($ip, 0, $pos); + } + + $ips[] = $ip; + } + } + + return $ips; + } + + /** + * Returns all hostnames for the given IPv4 or IPv6 address + * + * @param string $ip + * @return string[] + */ + public function getHostsForIp($ip) + { + // check binary representation of IP to avoid string case and short notation + $ip = @inet_pton($ip); + + $names = array(); + foreach (preg_split('/\r?\n/', $this->contents) as $line) { + $parts = preg_split('/\s+/', $line); + $addr = array_shift($parts); + + // remove IPv6 zone ID (`fe80::1%lo0` => `fe80:1`) + if (strpos($addr, ':') !== false && ($pos = strpos($addr, '%')) !== false) { + $addr = substr($addr, 0, $pos); + } + + if (@inet_pton($addr) === $ip) { + foreach ($parts as $part) { + $names[] = $part; + } + } + } + + return $names; + } +} diff --git a/src/Query/HostsFileExecutor.php b/src/Query/HostsFileExecutor.php new file mode 100644 index 00000000..0ca58bef --- /dev/null +++ b/src/Query/HostsFileExecutor.php @@ -0,0 +1,89 @@ +hosts = $hosts; + $this->fallback = $fallback; + } + + public function query($nameserver, Query $query) + { + if ($query->class === Message::CLASS_IN && ($query->type === Message::TYPE_A || $query->type === Message::TYPE_AAAA)) { + // forward lookup for type A or AAAA + $records = array(); + $expectsColon = $query->type === Message::TYPE_AAAA; + foreach ($this->hosts->getIpsForHost($query->name) as $ip) { + // ensure this is an IPv4/IPV6 address according to query type + if ((strpos($ip, ':') !== false) === $expectsColon) { + $records[] = new Record($query->name, $query->type, $query->class, 0, $ip); + } + } + + if ($records) { + return Promise\resolve( + Message::createResponseWithAnswersForQuery($query, $records) + ); + } + } elseif ($query->class === Message::CLASS_IN && $query->type === Message::TYPE_PTR) { + // reverse lookup: extract IPv4 or IPv6 from special `.arpa` domain + $ip = $this->getIpFromHost($query->name); + + if ($ip !== null) { + $records = array(); + foreach ($this->hosts->getHostsForIp($ip) as $host) { + $records[] = new Record($query->name, $query->type, $query->class, 0, $host); + } + + if ($records) { + return Promise\resolve( + Message::createResponseWithAnswersForQuery($query, $records) + ); + } + } + } + + return $this->fallback->query($nameserver, $query); + } + + private function getIpFromHost($host) + { + if (substr($host, -13) === '.in-addr.arpa') { + // IPv4: read as IP and reverse bytes + $ip = @inet_pton(substr($host, 0, -13)); + if ($ip === false || isset($ip[4])) { + return null; + } + + return inet_ntop(strrev($ip)); + } elseif (substr($host, -9) === '.ip6.arpa') { + // IPv6: replace dots, reverse nibbles and interpret as hexadecimal string + $ip = @inet_ntop(pack('H*', strrev(str_replace('.', '', substr($host, 0, -9))))); + if ($ip === false) { + return null; + } + + return $ip; + } else { + return null; + } + } +} diff --git a/src/Resolver/Factory.php b/src/Resolver/Factory.php index e781730f..12a912f0 100644 --- a/src/Resolver/Factory.php +++ b/src/Resolver/Factory.php @@ -4,21 +4,24 @@ use React\Cache\ArrayCache; use React\Cache\CacheInterface; -use React\Dns\Query\Executor; -use React\Dns\Query\CachedExecutor; -use React\Dns\Query\RecordCache; +use React\Dns\Config\HostsFile; use React\Dns\Protocol\Parser; use React\Dns\Protocol\BinaryDumper; -use React\EventLoop\LoopInterface; +use React\Dns\Query\CachedExecutor; +use React\Dns\Query\Executor; +use React\Dns\Query\ExecutorInterface; +use React\Dns\Query\HostsFileExecutor; +use React\Dns\Query\RecordCache; use React\Dns\Query\RetryExecutor; use React\Dns\Query\TimeoutExecutor; +use React\EventLoop\LoopInterface; class Factory { public function create($nameserver, LoopInterface $loop) { $nameserver = $this->addPortToServerIfMissing($nameserver); - $executor = $this->createRetryExecutor($loop); + $executor = $this->decorateHostsFileExecutor($this->createRetryExecutor($loop)); return new Resolver($nameserver, $executor); } @@ -30,11 +33,41 @@ public function createCached($nameserver, LoopInterface $loop, CacheInterface $c } $nameserver = $this->addPortToServerIfMissing($nameserver); - $executor = $this->createCachedExecutor($loop, $cache); + $executor = $this->decorateHostsFileExecutor($this->createCachedExecutor($loop, $cache)); return new Resolver($nameserver, $executor); } + /** + * Tries to load the hosts file and decorates the given executor on success + * + * @param ExecutorInterface $executor + * @return ExecutorInterface + * @codeCoverageIgnore + */ + private function decorateHostsFileExecutor(ExecutorInterface $executor) + { + try { + $executor = new HostsFileExecutor( + HostsFile::loadFromPathBlocking(), + $executor + ); + } catch (\RuntimeException $e) { + // ignore this file if it can not be loaded + } + + // Windows does not store localhost in hosts file by default but handles this internally + // To compensate for this, we explicitly use hard-coded defaults for localhost + if (DIRECTORY_SEPARATOR === '\\') { + $executor = new HostsFileExecutor( + new HostsFile("127.0.0.1 localhost\n::1 localhost"), + $executor + ); + } + + return $executor; + } + protected function createExecutor(LoopInterface $loop) { return new TimeoutExecutor( diff --git a/tests/Config/HostsFileTest.php b/tests/Config/HostsFileTest.php new file mode 100644 index 00000000..012934aa --- /dev/null +++ b/tests/Config/HostsFileTest.php @@ -0,0 +1,145 @@ +assertInstanceOf('React\Dns\Config\HostsFile', $hosts); + } + + public function testDefaultShouldHaveLocalhostMapped() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Not supported on Windows'); + } + + $hosts = HostsFile::loadFromPathBlocking(); + + $this->assertContains('127.0.0.1', $hosts->getIpsForHost('localhost')); + } + + /** + * @expectedException RuntimeException + */ + public function testLoadThrowsForInvalidPath() + { + HostsFile::loadFromPathBlocking('does/not/exist'); + } + + public function testContainsSingleLocalhostEntry() + { + $hosts = new HostsFile('127.0.0.1 localhost'); + + $this->assertEquals(array('127.0.0.1'), $hosts->getIpsForHost('localhost')); + $this->assertEquals(array(), $hosts->getIpsForHost('example.com')); + } + + public function testIgnoresIpv6ZoneId() + { + $hosts = new HostsFile('fe80::1%lo0 localhost'); + + $this->assertEquals(array('fe80::1'), $hosts->getIpsForHost('localhost')); + } + + public function testSkipsComments() + { + $hosts = new HostsFile('# start' . PHP_EOL .'#127.0.0.1 localhost' . PHP_EOL . '127.0.0.2 localhost # example.com'); + + $this->assertEquals(array('127.0.0.2'), $hosts->getIpsForHost('localhost')); + $this->assertEquals(array(), $hosts->getIpsForHost('example.com')); + } + + public function testContainsSingleLocalhostEntryWithCaseIgnored() + { + $hosts = new HostsFile('127.0.0.1 LocalHost'); + + $this->assertEquals(array('127.0.0.1'), $hosts->getIpsForHost('LOCALHOST')); + } + + public function testEmptyFileContainsNothing() + { + $hosts = new HostsFile(''); + + $this->assertEquals(array(), $hosts->getIpsForHost('example.com')); + } + + public function testSingleEntryWithMultipleNames() + { + $hosts = new HostsFile('127.0.0.1 localhost example.com'); + + $this->assertEquals(array('127.0.0.1'), $hosts->getIpsForHost('example.com')); + $this->assertEquals(array('127.0.0.1'), $hosts->getIpsForHost('localhost')); + } + + public function testMergesEntriesOverMultipleLines() + { + $hosts = new HostsFile("127.0.0.1 localhost\n127.0.0.2 localhost\n127.0.0.3 a localhost b\n127.0.0.4 a localhost"); + + $this->assertEquals(array('127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4'), $hosts->getIpsForHost('localhost')); + } + + public function testMergesIpv4AndIpv6EntriesOverMultipleLines() + { + $hosts = new HostsFile("127.0.0.1 localhost\n::1 localhost"); + + $this->assertEquals(array('127.0.0.1', '::1'), $hosts->getIpsForHost('localhost')); + } + + public function testReverseLookup() + { + $hosts = new HostsFile('127.0.0.1 localhost'); + + $this->assertEquals(array('localhost'), $hosts->getHostsForIp('127.0.0.1')); + $this->assertEquals(array(), $hosts->getHostsForIp('192.168.1.1')); + } + + public function testReverseNonIpReturnsNothing() + { + $hosts = new HostsFile('127.0.0.1 localhost'); + + $this->assertEquals(array(), $hosts->getHostsForIp('localhost')); + $this->assertEquals(array(), $hosts->getHostsForIp('127.0.0.1.1')); + } + + public function testReverseLookupReturnsLowerCaseHost() + { + $hosts = new HostsFile('127.0.0.1 LocalHost'); + + $this->assertEquals(array('localhost'), $hosts->getHostsForIp('127.0.0.1')); + } + + public function testReverseLookupChecksNormalizedIpv6() + { + $hosts = new HostsFile('FE80::00a1 localhost'); + + $this->assertEquals(array('localhost'), $hosts->getHostsForIp('fe80::A1')); + } + + public function testReverseLookupIgnoresIpv6ZoneId() + { + $hosts = new HostsFile('fe80::1%lo0 localhost'); + + $this->assertEquals(array('localhost'), $hosts->getHostsForIp('fe80::1')); + } + + public function testReverseLookupReturnsMultipleHostsOverSingleLine() + { + $hosts = new HostsFile("::1 ip6-localhost ip6-loopback"); + + $this->assertEquals(array('ip6-localhost', 'ip6-loopback'), $hosts->getHostsForIp('::1')); + } + + public function testReverseLookupReturnsMultipleHostsOverMultipleLines() + { + $hosts = new HostsFile("::1 ip6-localhost\n::1 ip6-loopback"); + + $this->assertEquals(array('ip6-localhost', 'ip6-loopback'), $hosts->getHostsForIp('::1')); + } +} diff --git a/tests/FunctionalResolverTest.php b/tests/FunctionalResolverTest.php index f7d0ee8d..4b3de34b 100644 --- a/tests/FunctionalResolverTest.php +++ b/tests/FunctionalResolverTest.php @@ -17,6 +17,14 @@ public function setUp() $this->resolver = $factory->create('8.8.8.8', $this->loop); } + public function testResolveLocalhostResolves() + { + $promise = $this->resolver->resolve('localhost'); + $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); + + $this->loop->run(); + } + public function testResolveGoogleResolves() { $promise = $this->resolver->resolve('google.com'); diff --git a/tests/Query/HostsFileExecutorTest.php b/tests/Query/HostsFileExecutorTest.php new file mode 100644 index 00000000..70d877ea --- /dev/null +++ b/tests/Query/HostsFileExecutorTest.php @@ -0,0 +1,126 @@ +hosts = $this->getMockBuilder('React\Dns\Config\HostsFile')->disableOriginalConstructor()->getMock(); + $this->fallback = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $this->executor = new HostsFileExecutor($this->hosts, $this->fallback); + } + + public function testDoesNotTryToGetIpsForMxQuery() + { + $this->hosts->expects($this->never())->method('getIpsForHost'); + $this->fallback->expects($this->once())->method('query'); + + $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_MX, Message::CLASS_IN, 0)); + } + + public function testFallsBackIfNoIpsWereFound() + { + $this->hosts->expects($this->once())->method('getIpsForHost')->willReturn(array()); + $this->fallback->expects($this->once())->method('query'); + + $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_A, Message::CLASS_IN, 0)); + } + + public function testReturnsResponseMessageIfIpsWereFound() + { + $this->hosts->expects($this->once())->method('getIpsForHost')->willReturn(array('127.0.0.1')); + $this->fallback->expects($this->never())->method('query'); + + $ret = $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_A, Message::CLASS_IN, 0)); + } + + public function testFallsBackIfNoIpv4Matches() + { + $this->hosts->expects($this->once())->method('getIpsForHost')->willReturn(array('::1')); + $this->fallback->expects($this->once())->method('query'); + + $ret = $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_A, Message::CLASS_IN, 0)); + } + + public function testReturnsResponseMessageIfIpv6AddressesWereFound() + { + $this->hosts->expects($this->once())->method('getIpsForHost')->willReturn(array('::1')); + $this->fallback->expects($this->never())->method('query'); + + $ret = $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_AAAA, Message::CLASS_IN, 0)); + } + + public function testFallsBackIfNoIpv6Matches() + { + $this->hosts->expects($this->once())->method('getIpsForHost')->willReturn(array('127.0.0.1')); + $this->fallback->expects($this->once())->method('query'); + + $ret = $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_AAAA, Message::CLASS_IN, 0)); + } + + public function testDoesReturnReverseIpv4Lookup() + { + $this->hosts->expects($this->once())->method('getHostsForIp')->with('127.0.0.1')->willReturn(array('localhost')); + $this->fallback->expects($this->never())->method('query'); + + $this->executor->query('8.8.8.8', new Query('1.0.0.127.in-addr.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + } + + public function testFallsBackIfNoReverseIpv4Matches() + { + $this->hosts->expects($this->once())->method('getHostsForIp')->with('127.0.0.1')->willReturn(array()); + $this->fallback->expects($this->once())->method('query'); + + $this->executor->query('8.8.8.8', new Query('1.0.0.127.in-addr.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + } + + public function testDoesReturnReverseIpv6Lookup() + { + $this->hosts->expects($this->once())->method('getHostsForIp')->with('2a02:2e0:3fe:100::6')->willReturn(array('ip6-localhost')); + $this->fallback->expects($this->never())->method('query'); + + $this->executor->query('8.8.8.8', new Query('6.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.e.f.3.0.0.e.2.0.2.0.a.2.ip6.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + } + + public function testFallsBackForInvalidAddress() + { + $this->hosts->expects($this->never())->method('getHostsForIp'); + $this->fallback->expects($this->once())->method('query'); + + $this->executor->query('8.8.8.8', new Query('example.com', Message::TYPE_PTR, Message::CLASS_IN, 0)); + } + + public function testReverseFallsBackForInvalidIpv4Address() + { + $this->hosts->expects($this->never())->method('getHostsForIp'); + $this->fallback->expects($this->once())->method('query'); + + $this->executor->query('8.8.8.8', new Query('::1.in-addr.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + } + + public function testReverseFallsBackForInvalidLengthIpv6Address() + { + $this->hosts->expects($this->never())->method('getHostsForIp'); + $this->fallback->expects($this->once())->method('query'); + + $this->executor->query('8.8.8.8', new Query('abcd.ip6.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + } + + public function testReverseFallsBackForInvalidHexIpv6Address() + { + $this->hosts->expects($this->never())->method('getHostsForIp'); + $this->fallback->expects($this->once())->method('query'); + + $this->executor->query('8.8.8.8', new Query('zZz.ip6.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + } +} diff --git a/tests/Resolver/FactoryTest.php b/tests/Resolver/FactoryTest.php index 85a4977f..acaeac07 100644 --- a/tests/Resolver/FactoryTest.php +++ b/tests/Resolver/FactoryTest.php @@ -4,6 +4,7 @@ use React\Dns\Resolver\Factory; use React\Tests\Dns\TestCase; +use React\Dns\Query\HostsFileExecutor; class FactoryTest extends TestCase { @@ -39,7 +40,7 @@ public function createCachedShouldCreateResolverWithCachedExecutor() $resolver = $factory->createCached('8.8.8.8:53', $loop); $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); - $executor = $this->getResolverPrivateMemberValue($resolver, 'executor'); + $executor = $this->getResolverPrivateExecutor($resolver); $this->assertInstanceOf('React\Dns\Query\CachedExecutor', $executor); $recordCache = $this->getCachedExecutorPrivateMemberValue($executor, 'cache'); $recordCacheCache = $this->getRecordCachePrivateMemberValue($recordCache, 'cache'); @@ -57,7 +58,7 @@ public function createCachedShouldCreateResolverWithCachedExecutorWithCustomCach $resolver = $factory->createCached('8.8.8.8:53', $loop, $cache); $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); - $executor = $this->getResolverPrivateMemberValue($resolver, 'executor'); + $executor = $this->getResolverPrivateExecutor($resolver); $this->assertInstanceOf('React\Dns\Query\CachedExecutor', $executor); $recordCache = $this->getCachedExecutorPrivateMemberValue($executor, 'cache'); $recordCacheCache = $this->getRecordCachePrivateMemberValue($recordCache, 'cache'); @@ -92,6 +93,21 @@ public static function factoryShouldAddDefaultPortProvider() ); } + private function getResolverPrivateExecutor($resolver) + { + $executor = $this->getResolverPrivateMemberValue($resolver, 'executor'); + + // extract underlying executor that may be wrapped in multiple layers of hosts file executors + while ($executor instanceof HostsFileExecutor) { + $reflector = new \ReflectionProperty('React\Dns\Query\HostsFileExecutor', 'fallback'); + $reflector->setAccessible(true); + + $executor = $reflector->getValue($executor); + } + + return $executor; + } + private function getResolverPrivateMemberValue($resolver, $field) { $reflector = new \ReflectionProperty('React\Dns\Resolver\Resolver', $field);