diff --git a/composer.json b/composer.json index 272bb15..8927932 100644 --- a/composer.json +++ b/composer.json @@ -25,13 +25,14 @@ } ], "require": { - "react/socket": "^0.8.10", - "react/datagram": "^1.4", - "php": "~7.1", + "ext-curl": "*", "ext-json": "~1.0", "ext-simplexml": "*", - "symfony/event-dispatcher": "~4.0", - "psr/log": "^1.0" + "php": "~7.1", + "psr/log": "^1.0", + "react/datagram": "^1.4", + "react/socket": "^0.8.10", + "symfony/event-dispatcher": "~4.0" }, "autoload": { "psr-4": { diff --git a/docs/Google-DNS.md b/docs/Google-DNS.md new file mode 100644 index 0000000..7b3338b --- /dev/null +++ b/docs/Google-DNS.md @@ -0,0 +1,12 @@ +# Google DNS + +Resolver class `GoogleDns.php` uses Google's DNS-over-HTTPS service to +resolve records. GoogleDNS resolver could be used as a drop in replacement +instead of `SystemResolver` to avoid eavesdropping on DNS requests. + +Upon DNS query server will issue HTTPS request to Google service and obtain +information on query, information will further be delivered to client in form of DNS response. + +Resolver at the moment supports `A` and `AAAA` type records. + +For more information refer to: https://developers.google.com/speed/public-dns/docs/dns-over-https diff --git a/example/google-dns-example.php b/example/google-dns-example.php new file mode 100644 index 0000000..c368e4e --- /dev/null +++ b/example/google-dns-example.php @@ -0,0 +1,14 @@ +addSubscriber(new \yswery\DNS\EchoLogger()); + +$server = new yswery\DNS\Server($stackableResolver, $eventDispatcher); + +$server->start(); diff --git a/src/Resolver/GoogleDnsResolver.php b/src/Resolver/GoogleDnsResolver.php new file mode 100644 index 0000000..08d9226 --- /dev/null +++ b/src/Resolver/GoogleDnsResolver.php @@ -0,0 +1,130 @@ +allowRecursion = true; + $this->isAuthoritative = true; + $this->defaultTtl = $defaultTtl; + } + + /** + * @param ResourceRecord[] $queries + * + * @return ResourceRecord[] + */ + public function getAnswer(array $queries): array + { + $answers = []; + foreach ($queries as $query) { + $response = $this->request($query->getName(), $query->getType()); + $answers[] = $this->createAnswer($query, $response); + } + + return array_merge(...$answers); + } + + /** + * @param ResourceRecord $query + * + * @param array|null $response + * + * @return ResourceRecord[] + */ + public function createAnswer(ResourceRecord $query, ?array $response): array + { + $answers = []; + + if (!is_array($response)) { + return [$this->getEmptyAnswer($query)]; + } + + if (!isset($response[self::ANSWER_FIELD_NAME]) || empty($response[self::ANSWER_FIELD_NAME])) { + return [$this->getEmptyAnswer($query)]; + } + + foreach ($response[self::ANSWER_FIELD_NAME] as $item) { + $answer = $this->getEmptyAnswer($query); + + $answer->setTtl($item[self::TTL_FIELD_NAME] ?? $this->defaultTtl); + + if ($query->getType() === RecordTypeEnum::TYPE_A && isset($item[self::DATA_FIELD_NAME])) { + $answer->setRdata($item[self::DATA_FIELD_NAME]); + } + + if ($query->getType() === RecordTypeEnum::TYPE_AAAA && isset($item[self::DATA_FIELD_NAME])) { + $answer->setRdata($item[self::DATA_FIELD_NAME]); + } + + $answers[] = $answer; + } + + return $answers; + } + + /** + * @param string $name + * @param string $type + * + * @return array|null + */ + private function request(string $name, string $type): ?array + { + $session = curl_init(); + + $query = [ + self::NAME_QUERY_PARAM => $name, + self::TYPE_QUERY_PARAM => $type, + ]; + + $url = self::API_ENDPOINT.'?'.http_build_query($query); + + curl_setopt($session, CURLOPT_RETURNTRANSFER, true); + curl_setopt($session, CURLOPT_URL, $url); + + $response = curl_exec($session); + + curl_close($session); + + $response = json_decode($response, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + + return $response; + } + + /** + * @param ResourceRecord $query + * + * @return ResourceRecord + */ + private function getEmptyAnswer(ResourceRecord $query): ResourceRecord + { + $answer = new ResourceRecord(); + $answer->setName($query->getName()); + $answer->setType($query->getType()); + $answer->setTtl($this->defaultTtl); + + return $answer; + } +} diff --git a/src/Server.php b/src/Server.php index c5313f3..91bc709 100644 --- a/src/Server.php +++ b/src/Server.php @@ -95,7 +95,7 @@ public function start(): void * @param string $address * @param SocketInterface $socket */ - public function onMessage(string $message, string $address, SocketInterface $socket) + public function onMessage(string $message, string $address, SocketInterface $socket): void { try { $this->dispatcher->dispatch(Events::MESSAGE, new MessageEvent($socket, $address, $message)); diff --git a/src/Tests/Resolver/GoogleDnsResolveTest.php b/src/Tests/Resolver/GoogleDnsResolveTest.php new file mode 100644 index 0000000..8519d70 --- /dev/null +++ b/src/Tests/Resolver/GoogleDnsResolveTest.php @@ -0,0 +1,79 @@ +resolver = new GoogleDnsResolver(300); + + $this->failureResponse = json_decode( + file_get_contents(__DIR__.'/../Resources/google-dns-query-failure.json'), + true + ); + $this->successResponse = json_decode( + file_get_contents(__DIR__.'/../Resources/google-dns-query-success.json'), + true + ); + } + + public function testRecordResolve(): void + { + $query = (new ResourceRecord()) + ->setName(self::EXAMPLE_QUERY) + ->setType(RecordTypeEnum::TYPE_A) + ->setQuestion(true); + + $answers = $this->resolver->createAnswer($query, $this->successResponse); + + static::assertArrayHasKey(0, $answers); + + $answer = $answers[0]; + + static::assertEquals('apple.com.', $answer->getName()); + static::assertEquals(RecordTypeEnum::TYPE_A, $answer->getType()); + static::assertEquals(3599, $answer->getTtl()); + static::assertEquals('17.178.96.59', $answer->getRdata()); + } + + public function testRecordFailedToResolve() { + $query = (new ResourceRecord()) + ->setName(self::EXAMPLE_QUERY) + ->setType(RecordTypeEnum::TYPE_A) + ->setQuestion(true); + + $answers = $this->resolver->createAnswer($query, $this->failureResponse); + + static::assertArrayHasKey(0, $answers); + + $answer = $answers[0]; + + static::assertEquals(self::EXAMPLE_QUERY, $answer->getName()); + static::assertEquals(RecordTypeEnum::TYPE_A, $answer->getType()); + static::assertEquals(300, $answer->getTtl()); + static::assertEquals(null, $answer->getRdata()); + } +} diff --git a/src/Tests/Resources/google-dns-query-failure.json b/src/Tests/Resources/google-dns-query-failure.json new file mode 100644 index 0000000..4c37433 --- /dev/null +++ b/src/Tests/Resources/google-dns-query-failure.json @@ -0,0 +1,16 @@ +{ + "Status": 2, + "TC": false, + "RD": true, + "RA": true, + "AD": false, + "CD": false, + "Question": + [ + { + "name": "dnssec-failed.org.", + "type": 1 + } + ], + "Comment": "DNSSEC validation failure. Please check http://dnsviz.net/d/dnssec-failed.org/dnssec/." +} diff --git a/src/Tests/Resources/google-dns-query-success.json b/src/Tests/Resources/google-dns-query-success.json new file mode 100644 index 0000000..fb77610 --- /dev/null +++ b/src/Tests/Resources/google-dns-query-success.json @@ -0,0 +1,38 @@ +{ + "Status": 0, + "TC": false, + "RD": true, + "RA": true, + "AD": false, + "CD": false, + "Question": + [ + { + "name": "apple.com.", + "type": 1 + } + ], + "Answer": + [ + { + "name": "apple.com.", + "type": 1, + "TTL": 3599, + "data": "17.178.96.59" + }, + { + "name": "apple.com.", + "type": 1, + "TTL": 3599, + "data": "17.172.224.47" + }, + { + "name": "apple.com.", + "type": 1, + "TTL": 3599, + "data": "17.142.160.59" + } + ], + "Additional": [ ], + "edns_client_subnet": "12.34.56.78/0" +}