Skip to content

Commit 3cdb1fe

Browse files
committed
Initial commit of DoH client
1 parent 5697ce8 commit 3cdb1fe

File tree

5 files changed

+297
-2
lines changed

5 files changed

+297
-2
lines changed

README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,23 @@
1-
# reactphp-dns-over-https-client
2-
DNS over HTTPS executor for ReactPHP/dns
1+
# DNS over HTTPS client for ReactPHP
2+
3+
Resolve DNS queries over HTTPS, provides secure DNS resolution over untrusted or shared networks (eg Serverless deployments) utilising standard HTTP queries.
4+
5+
## Requirements
6+
7+
The package is compatible with PHP 8.0+ and requires the [react/http](https://github.com/reactphp/http) library.
8+
9+
## Installation
10+
11+
You can add the library as project dependency using [Composer](https://getcomposer.org/):
12+
13+
```sh
14+
composer require edgetelemetrics/reactphp-dns-over-https-client
15+
```
16+
17+
## License
18+
19+
MIT, see [LICENSE file](LICENSE).
20+
21+
### Contributing
22+
23+
Bug reports (and small patches) can be submitted via the [issue tracker](https://github.com/lucasnetau/reactphp-dns-over-https-client/issues). Forking the repository and submitting a Pull Request is preferred for substantial patches.

composer.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "edgetelemetrics/reactphp-dns-over-https-client",
3+
"description": "description",
4+
"minimum-stability": "stable",
5+
"license": "MIT",
6+
"type": "library",
7+
"authors": [
8+
{
9+
"name": "James Lucas",
10+
"email": "[email protected]"
11+
}
12+
],
13+
"require": {
14+
"php": "^8.0",
15+
"react/http": "^1.6",
16+
"react/dns": "*"
17+
},
18+
"require-dev": {
19+
"phpunit/phpunit": "^9.5"
20+
},
21+
"suggest": {
22+
"ext-sodium": "LibSodium Base64 URL Safe Encoding"
23+
},
24+
"autoload": {
25+
"psr-4": {
26+
"EdgeTelemetrics\\React\\Dns\\": "src/"
27+
}
28+
},
29+
"autoload-dev": {
30+
"psr-4": {
31+
"EdgeTelemetrics\\React\\Dns\\Tests\\": "tests/"
32+
}
33+
}
34+
}

phpunit.xml.dist

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
3+
<!-- PHPUnit configuration file with new format for PHPUnit 9.3+ -->
4+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
6+
bootstrap="vendor/autoload.php"
7+
cacheResult="false"
8+
colors="true"
9+
convertDeprecationsToExceptions="true">
10+
<testsuites>
11+
<testsuite name="Library Test Suite">
12+
<directory>tests/</directory>
13+
</testsuite>
14+
</testsuites>
15+
<coverage>
16+
<include>
17+
<directory>src/</directory>
18+
</include>
19+
</coverage>
20+
</phpunit>

src/DohExecutor.php

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace EdgeTelemetrics\React\Dns;
4+
5+
use Psr\Http\Message\ResponseInterface;
6+
use React\Dns\Model\Message;
7+
use React\Dns\Protocol\BinaryDumper;
8+
use React\Dns\Protocol\Parser;
9+
use React\Dns\Query\ExecutorInterface;
10+
use React\Dns\Query\Query;
11+
use React\EventLoop\Loop;
12+
use React\EventLoop\LoopInterface;
13+
use React\Http\Browser;
14+
use React\Promise;
15+
use React\Socket\Connector;
16+
use RuntimeException;
17+
18+
class DohExecutor implements ExecutorInterface {
19+
20+
private $nameserver;
21+
private $loop;
22+
private $parser;
23+
private $dumper;
24+
25+
private $method;
26+
27+
private $browser;
28+
29+
const METHOD_GET = 'get';
30+
const METHOD_POST = 'post';
31+
32+
/**
33+
* @param string $nameserver
34+
* @param ?LoopInterface $loop
35+
*/
36+
public function __construct($nameserver, $method, LoopInterface $loop = null)
37+
{
38+
if (!class_exists('\React\Http\Browser')) {
39+
throw new RuntimeException('DNS over HTTPS support requires reactphp/http library'); //@codeCoverageIgnore
40+
}
41+
42+
if (!str_contains($nameserver, '[') && \substr_count($nameserver, ':') >= 2 && !str_contains($nameserver, '://')) {
43+
// several colons, but not enclosed in square brackets => enclose IPv6 address in square brackets
44+
$nameserver = '[' . $nameserver . ']';
45+
}
46+
47+
$parts = \parse_url((!str_contains($nameserver, '://') ? 'https://' : '') . $nameserver);
48+
if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'https' || @\inet_pton(\trim($parts['host'], '[]')) === false) {
49+
throw new \InvalidArgumentException('Invalid nameserver address given');
50+
}
51+
52+
$method = \strtolower($method);
53+
if (!in_array($method, [self::METHOD_GET, self::METHOD_POST], true)) {
54+
throw new \InvalidArgumentException('Invalid HTTP request method given');
55+
}
56+
57+
$this->nameserver = 'https://' . $parts['host'] . ':' . ($parts['port'] ?? 443 . '/dns-query');
58+
$this->loop = $loop ?: Loop::get();
59+
$this->parser = new Parser();
60+
$this->dumper = new BinaryDumper();
61+
$this->method = $method;
62+
$this->browser = (new Browser(new Connector(['tcp_nodelay' => true,]), $this->loop));
63+
}
64+
65+
public function query(Query $query)
66+
{
67+
$request = Message::createRequestForQuery($query);
68+
69+
$queryData = $this->dumper->toBinary($request);
70+
$length = \strlen($queryData);
71+
72+
if ($length > 0xffff) {
73+
return Promise\reject(new \RuntimeException(
74+
'DNS query for ' . $query->describe() . ' failed: Query too large for HTTPS transport'
75+
));
76+
}
77+
78+
if ($this->method === self::METHOD_GET) {
79+
$requestUrl = $this->nameserver . '?' . http_build_query(['dns' => $this->urlsafeBase64($queryData)]);
80+
$request = $this->browser->get($requestUrl);
81+
} else {
82+
$requestUrl = $this->nameserver;
83+
$request = $this->browser->post($requestUrl, [
84+
'accept' => 'application/dns-message',
85+
'content-type' => 'application/dns-message'
86+
], $queryData);
87+
}
88+
89+
return $request->then(function (ResponseInterface $response) {
90+
$response = $this->parser->parseMessage((string)$response->getBody());
91+
return Promise\resolve($response);
92+
}, function (\Exception $e) use ($query) {
93+
return Promise\reject(new \RuntimeException(
94+
'DNS query for ' . $query->describe() . ' failed: ' . $e->getMessage()
95+
));
96+
});
97+
}
98+
99+
/**
100+
* @param string $data
101+
* @return string
102+
*/
103+
private function urlsafeBase64(string $data) : string {
104+
// @codeCoverageIgnoreStart
105+
if (function_exists('sodium_bin2base64')) {
106+
return sodium_bin2base64($data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
107+
} else {
108+
return rtrim( strtr( base64_encode( $data ), '+/', '-_'), '=');
109+
}
110+
//@codeCoverageIgnoreEnd
111+
}
112+
}

tests/FunctionalTests.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace EdgeTelemetrics\React\Dns\Tests;
4+
5+
use EdgeTelemetrics\React\Dns\DohExecutor;
6+
use PHPUnit\Framework\TestCase;
7+
use React\Dns\Model\Message;
8+
use React\Dns\Query\Query;
9+
use React\EventLoop\Loop;
10+
11+
/**
12+
* Functional Tests
13+
*/
14+
class FunctionalTests extends TestCase
15+
{
16+
public function testResolveGoogleViaPostResolves()
17+
{
18+
$executor = new DohExecutor('https://1.1.1.1/dns-query', DohExecutor::METHOD_POST);
19+
$query = new Query('one.one.one.one', Message::TYPE_A, Message::CLASS_IN);
20+
$promise = $executor->query($query);
21+
22+
$answer = null;
23+
$promise->then(function ($message) use (&$answer) {
24+
$answer = $message;
25+
});
26+
27+
Loop::run();
28+
29+
$this->assertNotNull($answer);
30+
$this->assertEquals(Message::RCODE_OK, $answer->rcode);
31+
}
32+
33+
public function testResolveGoogleViaGetResolves()
34+
{
35+
$executor = new DohExecutor('https://1.1.1.1/dns-query', DohExecutor::METHOD_GET);
36+
$query = new Query('one.one.one.one', Message::TYPE_A, Message::CLASS_IN);
37+
$promise = $executor->query($query);
38+
39+
$answer = null;
40+
$promise->then(function ($message) use (&$answer) {
41+
$answer = $message;
42+
});
43+
44+
Loop::run();
45+
46+
$this->assertNotNull($answer);
47+
$this->assertEquals(Message::RCODE_OK, $answer->rcode);
48+
}
49+
50+
public function testResolveInvalidRejects()
51+
{
52+
$executor = new DohExecutor('https://1.1.1.1/dns-query', DohExecutor::METHOD_POST);
53+
$query = new Query('example.invalid', Message::TYPE_A, Message::CLASS_IN);
54+
$promise = $executor->query($query);
55+
56+
$answer = null;
57+
$promise->then(function ($message) use (&$answer) {
58+
$answer = $message;
59+
});
60+
61+
Loop::run();
62+
63+
$this->assertNotNull($answer);
64+
$this->assertEquals(Message::RCODE_NAME_ERROR, $answer->rcode);
65+
}
66+
67+
public function testResolveToInvalidServerRejects()
68+
{
69+
$executor = new DohExecutor('https://127.0.0.1:0/dns-query', DohExecutor::METHOD_POST);
70+
$query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN);
71+
$promise = $executor->query($query);
72+
73+
$exception = null;
74+
$promise->then(null, function ($reason) use (&$exception) {
75+
$exception = $reason;
76+
});
77+
78+
Loop::run();
79+
80+
$this->assertNotNull($exception);
81+
$this->assertInstanceOf(\RuntimeException::class, $exception);
82+
$this->assertStringStartsWith('DNS query for ' . $query->name . ' (A) failed: ', $exception->getMessage());
83+
}
84+
85+
public function testQueryRejectsIfMessageExceedsMaximumMessageSize()
86+
{
87+
$executor = $executor = new DohExecutor('https://127.0.0.1:0/dns-query', DohExecutor::METHOD_POST);
88+
89+
$query = new Query('google.' . str_repeat('.com', 60000), Message::TYPE_A, Message::CLASS_IN);
90+
$promise = $executor->query($query);
91+
92+
$exception = null;
93+
$promise->then(null, function ($reason) use (&$exception) {
94+
$exception = $reason;
95+
});
96+
97+
/** @var \RuntimeException $exception */
98+
$this->assertInstanceOf('RuntimeException', $exception);
99+
$this->assertStringStartsWith('DNS query for '. $query->name . ' (A) failed: Query too large for HTTPS transport', $exception->getMessage());
100+
}
101+
102+
public function testResolveViaInvalidHttpMethodThrows()
103+
{
104+
$this->expectException(\InvalidArgumentException::class);
105+
106+
new DohExecutor('https://1.1.1.1/dns-query', 'put');
107+
}
108+
}

0 commit comments

Comments
 (0)