Skip to content

Commit d52e7e8

Browse files
authored
Merge pull request #75 from clue-labs/hosts
Support resolving from default hosts file
2 parents 457fbc4 + df7f4e1 commit d52e7e8

8 files changed

Lines changed: 595 additions & 8 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ easily be used to create a DNS server.
1414
* [Caching](#caching)
1515
* [Custom cache adapter](#custom-cache-adapter)
1616
* [Advanced usage](#advanced-usage)
17+
* [HostsFileExecutor](#hostsfileexecutor)
1718
* [Install](#install)
1819
* [Tests](#tests)
1920
* [License](#license)
@@ -39,6 +40,11 @@ $loop->run();
3940

4041
See also the [first example](examples).
4142

43+
> Note that the factory loads the hosts file from the filesystem once when
44+
creating the resolver instance.
45+
Ideally, this method should thus be executed only once before the loop starts
46+
and not repeatedly while it is running.
47+
4248
Pending DNS queries can be cancelled by cancelling its pending promise like so:
4349

4450
```php
@@ -118,6 +124,24 @@ $loop->run();
118124

119125
See also the [fourth example](examples).
120126

127+
### HostsFileExecutor
128+
129+
Note that the above `Executor` class always performs an actual DNS query.
130+
If you also want to take entries from your hosts file into account, you may
131+
use this code:
132+
133+
```php
134+
$hosts = \React\Dns\Config\HostsFile::loadFromPathBlocking();
135+
136+
$executor = new Executor($loop, new Parser(), new BinaryDumper(), null);
137+
$executor = new HostsFileExecutor($hosts, $executor);
138+
139+
$executor->query(
140+
'8.8.8.8:53',
141+
new Query('localhost', Message::TYPE_A, Message::CLASS_IN, time())
142+
);
143+
```
144+
121145
## Install
122146

123147
The recommended way to install this library is [through Composer](http://getcomposer.org).

src/Config/HostsFile.php

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php
2+
3+
namespace React\Dns\Config;
4+
5+
use RuntimeException;
6+
7+
/**
8+
* Represents a static hosts file which maps hostnames to IPs
9+
*
10+
* Hosts files are used on most systems to avoid actually hitting the DNS for
11+
* certain common hostnames.
12+
*
13+
* Most notably, this file usually contains an entry to map "localhost" to the
14+
* local IP. Windows is a notable exception here, as Windows does not actually
15+
* include "localhost" in this file by default. To compensate for this, this
16+
* class may explicitly be wrapped in another HostsFile instance which
17+
* hard-codes these entries for Windows (see also Factory).
18+
*
19+
* This class mostly exists to abstract the parsing/extraction process so this
20+
* can be replaced with a faster alternative in the future.
21+
*/
22+
class HostsFile
23+
{
24+
/**
25+
* Returns the default path for the hosts file on this system
26+
*
27+
* @return string
28+
* @codeCoverageIgnore
29+
*/
30+
public static function getDefaultPath()
31+
{
32+
// use static path for all Unix-based systems
33+
if (DIRECTORY_SEPARATOR !== '\\') {
34+
return '/etc/hosts';
35+
}
36+
37+
// Windows actually stores the path in the registry under
38+
// \HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\DataBasePath
39+
$path = '%SystemRoot%\\system32\drivers\etc\hosts';
40+
41+
$base = getenv('SystemRoot');
42+
if ($base === false) {
43+
$base = 'C:\\Windows';
44+
}
45+
46+
return str_replace('%SystemRoot%', $base, $path);
47+
}
48+
49+
/**
50+
* Loads a hosts file (from the given path or default location)
51+
*
52+
* Note that this method blocks while loading the given path and should
53+
* thus be used with care! While this should be relatively fast for normal
54+
* hosts file, this may be an issue if this file is located on a slow device
55+
* or contains an excessive number of entries. In particular, this method
56+
* should only be executed before the loop starts, not while it is running.
57+
*
58+
* @param ?string $path (optional) path to hosts file or null=load default location
59+
* @return self
60+
* @throws RuntimeException if the path can not be loaded (does not exist)
61+
*/
62+
public static function loadFromPathBlocking($path = null)
63+
{
64+
if ($path === null) {
65+
$path = self::getDefaultPath();
66+
}
67+
68+
$contents = @file_get_contents($path);
69+
if ($contents === false) {
70+
throw new RuntimeException('Unable to load hosts file "' . $path . '"');
71+
}
72+
73+
return new self($contents);
74+
}
75+
76+
/**
77+
* Instantiate new hosts file with the given hosts file contents
78+
*
79+
* @param string $contents
80+
*/
81+
public function __construct($contents)
82+
{
83+
// remove all comments from the contents
84+
$contents = preg_replace('/ *#.*/', '', strtolower($contents));
85+
86+
$this->contents = $contents;
87+
}
88+
89+
/**
90+
* Returns all IPs for the given hostname
91+
*
92+
* @param string $name
93+
* @return string[]
94+
*/
95+
public function getIpsForHost($name)
96+
{
97+
$name = strtolower($name);
98+
99+
$ips = array();
100+
foreach (preg_split('/\r?\n/', $this->contents) as $line) {
101+
$parts = preg_split('/\s+/', $line);
102+
$ip = array_shift($parts);
103+
if ($parts && array_search($name, $parts) !== false) {
104+
// remove IPv6 zone ID (`fe80::1%lo0` => `fe80:1`)
105+
if (strpos($ip, ':') !== false && ($pos = strpos($ip, '%')) !== false) {
106+
$ip= substr($ip, 0, $pos);
107+
}
108+
109+
$ips[] = $ip;
110+
}
111+
}
112+
113+
return $ips;
114+
}
115+
116+
/**
117+
* Returns all hostnames for the given IPv4 or IPv6 address
118+
*
119+
* @param string $ip
120+
* @return string[]
121+
*/
122+
public function getHostsForIp($ip)
123+
{
124+
// check binary representation of IP to avoid string case and short notation
125+
$ip = @inet_pton($ip);
126+
127+
$names = array();
128+
foreach (preg_split('/\r?\n/', $this->contents) as $line) {
129+
$parts = preg_split('/\s+/', $line);
130+
$addr = array_shift($parts);
131+
132+
// remove IPv6 zone ID (`fe80::1%lo0` => `fe80:1`)
133+
if (strpos($addr, ':') !== false && ($pos = strpos($addr, '%')) !== false) {
134+
$addr = substr($addr, 0, $pos);
135+
}
136+
137+
if (@inet_pton($addr) === $ip) {
138+
foreach ($parts as $part) {
139+
$names[] = $part;
140+
}
141+
}
142+
}
143+
144+
return $names;
145+
}
146+
}

src/Query/HostsFileExecutor.php

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
namespace React\Dns\Query;
4+
5+
use React\Dns\Config\HostsFile;
6+
use React\Dns\Model\Message;
7+
use React\Dns\Model\Record;
8+
use React\Promise;
9+
10+
/**
11+
* Resolves hosts from the givne HostsFile or falls back to another executor
12+
*
13+
* If the host is found in the hosts file, it will not be passed to the actual
14+
* DNS executor. If the host is not found in the hosts file, it will be passed
15+
* to the DNS executor as a fallback.
16+
*/
17+
class HostsFileExecutor implements ExecutorInterface
18+
{
19+
private $hosts;
20+
private $fallback;
21+
22+
public function __construct(HostsFile $hosts, ExecutorInterface $fallback)
23+
{
24+
$this->hosts = $hosts;
25+
$this->fallback = $fallback;
26+
}
27+
28+
public function query($nameserver, Query $query)
29+
{
30+
if ($query->class === Message::CLASS_IN && ($query->type === Message::TYPE_A || $query->type === Message::TYPE_AAAA)) {
31+
// forward lookup for type A or AAAA
32+
$records = array();
33+
$expectsColon = $query->type === Message::TYPE_AAAA;
34+
foreach ($this->hosts->getIpsForHost($query->name) as $ip) {
35+
// ensure this is an IPv4/IPV6 address according to query type
36+
if ((strpos($ip, ':') !== false) === $expectsColon) {
37+
$records[] = new Record($query->name, $query->type, $query->class, 0, $ip);
38+
}
39+
}
40+
41+
if ($records) {
42+
return Promise\resolve(
43+
Message::createResponseWithAnswersForQuery($query, $records)
44+
);
45+
}
46+
} elseif ($query->class === Message::CLASS_IN && $query->type === Message::TYPE_PTR) {
47+
// reverse lookup: extract IPv4 or IPv6 from special `.arpa` domain
48+
$ip = $this->getIpFromHost($query->name);
49+
50+
if ($ip !== null) {
51+
$records = array();
52+
foreach ($this->hosts->getHostsForIp($ip) as $host) {
53+
$records[] = new Record($query->name, $query->type, $query->class, 0, $host);
54+
}
55+
56+
if ($records) {
57+
return Promise\resolve(
58+
Message::createResponseWithAnswersForQuery($query, $records)
59+
);
60+
}
61+
}
62+
}
63+
64+
return $this->fallback->query($nameserver, $query);
65+
}
66+
67+
private function getIpFromHost($host)
68+
{
69+
if (substr($host, -13) === '.in-addr.arpa') {
70+
// IPv4: read as IP and reverse bytes
71+
$ip = @inet_pton(substr($host, 0, -13));
72+
if ($ip === false || isset($ip[4])) {
73+
return null;
74+
}
75+
76+
return inet_ntop(strrev($ip));
77+
} elseif (substr($host, -9) === '.ip6.arpa') {
78+
// IPv6: replace dots, reverse nibbles and interpret as hexadecimal string
79+
$ip = @inet_ntop(pack('H*', strrev(str_replace('.', '', substr($host, 0, -9)))));
80+
if ($ip === false) {
81+
return null;
82+
}
83+
84+
return $ip;
85+
} else {
86+
return null;
87+
}
88+
}
89+
}

src/Resolver/Factory.php

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,24 @@
44

55
use React\Cache\ArrayCache;
66
use React\Cache\CacheInterface;
7-
use React\Dns\Query\Executor;
8-
use React\Dns\Query\CachedExecutor;
9-
use React\Dns\Query\RecordCache;
7+
use React\Dns\Config\HostsFile;
108
use React\Dns\Protocol\Parser;
119
use React\Dns\Protocol\BinaryDumper;
12-
use React\EventLoop\LoopInterface;
10+
use React\Dns\Query\CachedExecutor;
11+
use React\Dns\Query\Executor;
12+
use React\Dns\Query\ExecutorInterface;
13+
use React\Dns\Query\HostsFileExecutor;
14+
use React\Dns\Query\RecordCache;
1315
use React\Dns\Query\RetryExecutor;
1416
use React\Dns\Query\TimeoutExecutor;
17+
use React\EventLoop\LoopInterface;
1518

1619
class Factory
1720
{
1821
public function create($nameserver, LoopInterface $loop)
1922
{
2023
$nameserver = $this->addPortToServerIfMissing($nameserver);
21-
$executor = $this->createRetryExecutor($loop);
24+
$executor = $this->decorateHostsFileExecutor($this->createRetryExecutor($loop));
2225

2326
return new Resolver($nameserver, $executor);
2427
}
@@ -30,11 +33,41 @@ public function createCached($nameserver, LoopInterface $loop, CacheInterface $c
3033
}
3134

3235
$nameserver = $this->addPortToServerIfMissing($nameserver);
33-
$executor = $this->createCachedExecutor($loop, $cache);
36+
$executor = $this->decorateHostsFileExecutor($this->createCachedExecutor($loop, $cache));
3437

3538
return new Resolver($nameserver, $executor);
3639
}
3740

41+
/**
42+
* Tries to load the hosts file and decorates the given executor on success
43+
*
44+
* @param ExecutorInterface $executor
45+
* @return ExecutorInterface
46+
* @codeCoverageIgnore
47+
*/
48+
private function decorateHostsFileExecutor(ExecutorInterface $executor)
49+
{
50+
try {
51+
$executor = new HostsFileExecutor(
52+
HostsFile::loadFromPathBlocking(),
53+
$executor
54+
);
55+
} catch (\RuntimeException $e) {
56+
// ignore this file if it can not be loaded
57+
}
58+
59+
// Windows does not store localhost in hosts file by default but handles this internally
60+
// To compensate for this, we explicitly use hard-coded defaults for localhost
61+
if (DIRECTORY_SEPARATOR === '\\') {
62+
$executor = new HostsFileExecutor(
63+
new HostsFile("127.0.0.1 localhost\n::1 localhost"),
64+
$executor
65+
);
66+
}
67+
68+
return $executor;
69+
}
70+
3871
protected function createExecutor(LoopInterface $loop)
3972
{
4073
return new TimeoutExecutor(

0 commit comments

Comments
 (0)