diff --git a/docker-compose.yml b/docker-compose.yml index 7c79bf5..cfddca0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,8 @@ services: - ./tests:/usr/src/code/tests - ./phpunit.xml:/usr/src/code/phpunit.xml dns: - - 127.0.0.1 + - 8.8.8.8 + - 1.1.1.1 networks: - dns ports: diff --git a/src/DNS/Http/Client.php b/src/DNS/Http/Client.php new file mode 100644 index 0000000..f8aa9f1 --- /dev/null +++ b/src/DNS/Http/Client.php @@ -0,0 +1,223 @@ +method === self::METHOD_GET) { + return $this->queryGet($message); + } + + return $this->queryPost($message); + } + + /** + * Send a DNS query using HTTP POST method + * + * RFC 8484 Section 4.1: POST request with application/dns-message body + * + * @param Message $message The DNS query message + * @return Message The DNS response message + */ + protected function queryPost(Message $message): Message + { + $packet = $message->encode(); + + $ch = curl_init(); + + if ($ch === false) { + throw new Exception('Failed to initialize cURL handle.'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $this->endpoint, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $packet, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_CONNECTTIMEOUT => $this->connectTimeout, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/dns-message', + 'Accept: application/dns-message', + ], + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + $errno = curl_errno($ch); + + curl_close($ch); + + if ($errno !== 0) { + throw new Exception("DoH request failed: $error (Error code: $errno)"); + } + + if ($httpCode !== 200) { + throw new Exception("DoH server returned HTTP $httpCode"); + } + + if (!is_string($response) || $response === '') { + throw new Exception('Empty response received from DoH server'); + } + + return $this->decodeResponse($message, $response); + } + + /** + * Send a DNS query using HTTP GET method + * + * RFC 8484 Section 4.1: GET request with base64url-encoded dns parameter + * + * @param Message $message The DNS query message + * @return Message The DNS response message + */ + protected function queryGet(Message $message): Message + { + $packet = $message->encode(); + $encoded = $this->base64UrlEncode($packet); + + $separator = str_contains($this->endpoint, '?') ? '&' : '?'; + $url = $this->endpoint . $separator . 'dns=' . $encoded; + + $ch = curl_init(); + + if ($ch === false) { + throw new Exception('Failed to initialize cURL handle.'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_CONNECTTIMEOUT => $this->connectTimeout, + CURLOPT_HTTPHEADER => [ + 'Accept: application/dns-message', + ], + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + $errno = curl_errno($ch); + + curl_close($ch); + + if ($errno !== 0) { + throw new Exception("DoH request failed: $error (Error code: $errno)"); + } + + if ($httpCode !== 200) { + throw new Exception("DoH server returned HTTP $httpCode"); + } + + if (!is_string($response) || $response === '') { + throw new Exception('Empty response received from DoH server'); + } + + return $this->decodeResponse($message, $response); + } + + /** + * Decode the DNS response and validate the transaction ID + * + * @param Message $query Original query message + * @param string $payload Raw response data + * @return Message Decoded response message + */ + protected function decodeResponse(Message $query, string $payload): Message + { + $response = Message::decode($payload); + + if ($response->header->id !== $query->header->id) { + throw new Exception( + "Mismatched DNS transaction ID. Expected {$query->header->id}, got {$response->header->id}" + ); + } + + return $response; + } + + /** + * Encode data using base64url encoding (RFC 4648 Section 5) + * + * Required for the GET method as per RFC 8484 Section 4.1 + * + * @param string $data Binary data to encode + * @return string Base64url-encoded string (no padding) + */ + protected function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * Get the DoH endpoint URL + * + * @return string The endpoint URL + */ + public function getEndpoint(): string + { + return $this->endpoint; + } + + /** + * Get the HTTP method being used + * + * @return string The HTTP method (GET or POST) + */ + public function getMethod(): string + { + return $this->method; + } +} diff --git a/src/DNS/Resolver/Http.php b/src/DNS/Resolver/Http.php new file mode 100644 index 0000000..a7d22c6 --- /dev/null +++ b/src/DNS/Resolver/Http.php @@ -0,0 +1,68 @@ +endpoint = $endpoint; + $this->client = new Client($endpoint, $timeout, $connectTimeout, $method); + } + + /** + * Resolve DNS query by forwarding to the DoH server + * + * @param Message $query The DNS query message + * @return Message The DNS response message + */ + public function resolve(Message $query): Message + { + return $this->client->query($query); + } + + /** + * Get the name of the resolver + * + * @return string The resolver name + */ + public function getName(): string + { + return "HTTP ($this->endpoint)"; + } + + /** + * Get the underlying HTTP client + * + * @return Client The client instance + */ + public function getClient(): Client + { + return $this->client; + } +} diff --git a/src/DNS/Resolver/Http/Cloudflare.php b/src/DNS/Resolver/Http/Cloudflare.php new file mode 100644 index 0000000..98fcf88 --- /dev/null +++ b/src/DNS/Resolver/Http/Cloudflare.php @@ -0,0 +1,49 @@ +endpoint)"; + } +} diff --git a/src/DNS/Resolver/Http/Google.php b/src/DNS/Resolver/Http/Google.php new file mode 100644 index 0000000..5aff868 --- /dev/null +++ b/src/DNS/Resolver/Http/Google.php @@ -0,0 +1,49 @@ +endpoint)"; + } +} diff --git a/tests/e2e/DNS/Resolver/Http/CloudflareTest.php b/tests/e2e/DNS/Resolver/Http/CloudflareTest.php new file mode 100644 index 0000000..31b91d9 --- /dev/null +++ b/tests/e2e/DNS/Resolver/Http/CloudflareTest.php @@ -0,0 +1,129 @@ +resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_A, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)); + } + + public function testResolveGoogleAWithGet(): void + { + $resolver = new Cloudflare(useBackup: false, method: Client::METHOD_GET); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_A, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)); + } + + public function testResolveGoogleAAAA(): void + { + $resolver = new Cloudflare(); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_AAAA + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_AAAA, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)); + } + + public function testResolveMXRecord(): void + { + $resolver = new Cloudflare(); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_MX + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_MX, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotNull($record->priority); + } + + public function testResolveWithBackupEndpoint(): void + { + $resolver = new Cloudflare(useBackup: true); + + $this->assertSame(Cloudflare::ENDPOINT_BACKUP, $resolver->getClient()->getEndpoint()); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'example.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + } + + public function testGetName(): void + { + $resolver = new Cloudflare(); + $this->assertStringContainsString('Cloudflare HTTP', $resolver->getName()); + $this->assertStringContainsString(Cloudflare::ENDPOINT_PRIMARY, $resolver->getName()); + + $resolverBackup = new Cloudflare(useBackup: true); + $this->assertStringContainsString(Cloudflare::ENDPOINT_BACKUP, $resolverBackup->getName()); + } + + public function testEndpointConstants(): void + { + $this->assertSame('https://cloudflare-dns.com/dns-query', Cloudflare::ENDPOINT_PRIMARY); + $this->assertSame('https://one.one.one.one/dns-query', Cloudflare::ENDPOINT_BACKUP); + } +} diff --git a/tests/e2e/DNS/Resolver/Http/GoogleTest.php b/tests/e2e/DNS/Resolver/Http/GoogleTest.php new file mode 100644 index 0000000..36f4a23 --- /dev/null +++ b/tests/e2e/DNS/Resolver/Http/GoogleTest.php @@ -0,0 +1,129 @@ +resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_A, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)); + } + + public function testResolveGoogleAWithGet(): void + { + $resolver = new Google(useBackup: false, method: Client::METHOD_GET); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_A, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)); + } + + public function testResolveGoogleAAAA(): void + { + $resolver = new Google(); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_AAAA + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_AAAA, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)); + } + + public function testResolveMXRecord(): void + { + $resolver = new Google(); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_MX + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_MX, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotNull($record->priority); + } + + public function testResolveWithBackupEndpoint(): void + { + $resolver = new Google(useBackup: true); + + $this->assertSame(Google::ENDPOINT_BACKUP, $resolver->getClient()->getEndpoint()); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'example.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + } + + public function testGetName(): void + { + $resolver = new Google(); + $this->assertStringContainsString('Google HTTP', $resolver->getName()); + $this->assertStringContainsString(Google::ENDPOINT_PRIMARY, $resolver->getName()); + + $resolverBackup = new Google(useBackup: true); + $this->assertStringContainsString(Google::ENDPOINT_BACKUP, $resolverBackup->getName()); + } + + public function testEndpointConstants(): void + { + $this->assertSame('https://dns.google/dns-query', Google::ENDPOINT_PRIMARY); + $this->assertSame('https://8.8.8.8/dns-query', Google::ENDPOINT_BACKUP); + } +} diff --git a/tests/e2e/DNS/Resolver/HttpTest.php b/tests/e2e/DNS/Resolver/HttpTest.php new file mode 100644 index 0000000..5eb6e91 --- /dev/null +++ b/tests/e2e/DNS/Resolver/HttpTest.php @@ -0,0 +1,122 @@ +resolve(Message::query( + new Question( + name: 'example.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_A, $record->type); + $this->assertSame('example.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)); + } + + public function testResolveWithGetMethod(): void + { + $resolver = new Http('https://cloudflare-dns.com/dns-query', 5, 2, Client::METHOD_GET); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'example.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + } + + public function testResolveWithPostMethod(): void + { + $resolver = new Http('https://cloudflare-dns.com/dns-query', 5, 2, Client::METHOD_POST); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'example.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + } + + public function testGetName(): void + { + $resolver = new Http('https://custom-dns.example.com/dns-query'); + $this->assertSame('HTTP (https://custom-dns.example.com/dns-query)', $resolver->getName()); + } + + public function testGetClient(): void + { + $resolver = new Http('https://cloudflare-dns.com/dns-query', 10, 2, Client::METHOD_GET); + $client = $resolver->getClient(); + + $this->assertInstanceOf(Client::class, $client); + $this->assertSame('https://cloudflare-dns.com/dns-query', $client->getEndpoint()); + $this->assertSame(Client::METHOD_GET, $client->getMethod()); + } + + public function testResolveTXTRecord(): void + { + $resolver = new Http('https://cloudflare-dns.com/dns-query'); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_TXT + ) + )); + + $this->assertNotEmpty($response->answers); + + $hasTxt = false; + foreach ($response->answers as $record) { + if ($record->type === Record::TYPE_TXT) { + $hasTxt = true; + break; + } + } + $this->assertTrue($hasTxt, 'Response should contain TXT records'); + } + + public function testResolveNSRecord(): void + { + $resolver = new Http('https://cloudflare-dns.com/dns-query'); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_NS + ) + )); + + $this->assertNotEmpty($response->answers); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_NS, $record->type); + $this->assertSame('google.com', $record->name); + } +} diff --git a/tests/unit/DNS/Http/ClientTest.php b/tests/unit/DNS/Http/ClientTest.php new file mode 100644 index 0000000..3419844 --- /dev/null +++ b/tests/unit/DNS/Http/ClientTest.php @@ -0,0 +1,63 @@ +expectException(\Exception::class); + $this->expectExceptionMessage('Invalid DoH endpoint URL. Must be a valid HTTPS URL.'); + + new Client('not-a-valid-url'); + } + + public function testConstructorRejectsHttpScheme(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid DoH endpoint URL. Must be a valid HTTPS URL.'); + + new Client('http://example.com/dns-query'); + } + + public function testConstructorValidatesMethod(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid HTTP method. Use GET or POST.'); + + new Client('https://cloudflare-dns.com/dns-query', 5, 2, 'PUT'); + } + + public function testConstructorAcceptsValidEndpoint(): void + { + $client = new Client('https://cloudflare-dns.com/dns-query'); + + $this->assertSame('https://cloudflare-dns.com/dns-query', $client->getEndpoint()); + $this->assertSame(Client::METHOD_POST, $client->getMethod()); + } + + public function testConstructorAcceptsGetMethod(): void + { + $client = new Client('https://dns.google/dns-query', 5, 2, Client::METHOD_GET); + + $this->assertSame('https://dns.google/dns-query', $client->getEndpoint()); + $this->assertSame(Client::METHOD_GET, $client->getMethod()); + } + + public function testConstructorAcceptsPostMethod(): void + { + $client = new Client('https://dns.google/dns-query', 5, 2, Client::METHOD_POST); + + $this->assertSame('https://dns.google/dns-query', $client->getEndpoint()); + $this->assertSame(Client::METHOD_POST, $client->getMethod()); + } + + public function testMethodConstants(): void + { + $this->assertSame('GET', Client::METHOD_GET); + $this->assertSame('POST', Client::METHOD_POST); + } +}