Skip to content

Commit

Permalink
[3.0] Support resolv.conf search directive
Browse files Browse the repository at this point in the history
  • Loading branch information
WyriHaximus committed Feb 22, 2025
1 parent 30c5e43 commit dfee65f
Show file tree
Hide file tree
Showing 7 changed files with 436 additions and 25 deletions.
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

0 comments on commit dfee65f

Please sign in to comment.