Skip to content

[3.0] Support resolv.conf search directive #232

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: 3.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/Config/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -134,4 +168,16 @@ public static function loadWmicBlocking($command = null)
}

public $nameservers = [];
/**
* @var array<string>
*/
public $search = [];
/**
* @var Options
*/
public $options;

public function __construct() {
$this->options = new Options();
}
}
21 changes: 21 additions & 0 deletions src/Config/Options.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace React\Dns\Config;

use RuntimeException;

final class Options
{
/**
* @var int<0, 15>
*/
public $ndots = 1;
/**
* @var int<1, 5>
*/
public $attempts = 2;
/**
* @var int<1, 30>
*/
public $timeout = 5;
}
132 changes: 132 additions & 0 deletions src/Query/SearchingExecutor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

namespace React\Dns\Query;

use React\Dns\Model\Message;
use React\Promise\PromiseInterface;
use function React\Promise\resolve;

/**
* In sequence attempt to find Fully Qualified Domain Name (FQDN) when not
* explicitly queried for, and when the amount of dots in the name is equal
* or greater than the passed ndots parameter.
*
* Wraps an existing `ExecutorInterface` to do the actual querying and only
* concerns itself with working through the list of search domains until a
* matching answer comes back from the resolver.
*
* This might cause a delay for domains that are never meant to be search
* such as public domains like exampled.com, as such always query with a
* FQDN when searching isn't required.
*
* This is useful in situations like Kubernetes where you might only know
* a services name and namespace but don't know the rest of the clusters
* LOOK UP svc.cluster.local and want to rely on the resolve.conf injected
* into pods.
*
* This is a high level executor, and it should be placed between the
* RetryExecutor and the CoopExecutor. So transient networking issues
* are handled through the RetryExecutor and other low level executors.
* And no duplicated queries are sent out with CoopExecutor.
*
* ```php
* $executor = new CoopExecutor(
* new SearchingExecutor(
* new RetryExecutor(
* new TimeoutExecutor(
* new UdpTransportExecutor($nameserver),
* 3.0
* )
* ),
* 5,
* 'svc',
* 'svc.cluster',
* 'svc.cluster.local'
* )
* );
* ```
*/
final class SearchingExecutor implements ExecutorInterface
{
/**
* @var ExecutorInterface
*/
private $executor;
/**
* @var int
*/
private $ndots;
/**
* @var array<string>
*/
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;
}
}
63 changes: 38 additions & 25 deletions src/Resolver/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -125,54 +127,63 @@ 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
$nameserver = $primary;
}
}

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)
);
}

Expand All @@ -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
);
}
Expand Down
4 changes: 4 additions & 0 deletions tests/Config/ConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions tests/Fixtures/etc/resolv.conf
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading