Issues (3)

src/DomainResolver.php (2 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * Copyright (c) Ne-Lexa
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 *
11
 * @see https://github.com/Ne-Lexa/guzzle-doh-middleware
12
 */
13
14
namespace Nelexa\Doh;
15
16
use GuzzleHttp\Client;
17
use GuzzleHttp\RequestOptions;
18
use LibDNS\Decoder\DecoderFactory;
19
use LibDNS\Messages\Message;
20
use LibDNS\Records\Record;
21
use LibDNS\Records\Resource;
22
use LibDNS\Records\ResourceTypes;
23
use Nelexa\Doh\Storage\DnsRecord;
24
use Nelexa\Doh\Storage\StorageInterface;
25
26
final class DomainResolver
27
{
28
    /** @var \GuzzleHttp\Client */
29
    private $client;
30
31
    /** @var \Nelexa\Doh\Storage\StorageInterface */
32
    private $storage;
33
34
    /** @var string[] */
35
    private $dohServers;
36
37
    /**
38
     * @param \Nelexa\Doh\Storage\StorageInterface $storage
39
     * @param string[]                             $dohServers
40
     * @param bool                                 $debug
41
     */
42 14
    public function __construct(StorageInterface $storage, array $dohServers, bool $debug)
43
    {
44 14
        $this->storage = $storage;
45 14
        $this->dohServers = empty($dohServers) ? DohServers::DEFAULT_SERVERS : $dohServers;
46
47 14
        $curlOptions = [];
48
49 14
        if (\defined('CURLOPT_IPRESOLVE') && \defined('CURL_IPRESOLVE_V4')) {
50 14
            $curlOptions[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V4;
51
        }
52
53 14
        $this->client = new Client([
54
            RequestOptions::HEADERS => [
55
                'Accept' => 'application/dns-udpwireformat, application/dns-message',
56
                'User-Agent' => 'DoH-Client',
57
            ],
58
            RequestOptions::CONNECT_TIMEOUT => 10.0,
59
            RequestOptions::TIMEOUT => 10.0,
60
            RequestOptions::VERSION => '2.0',
61
            RequestOptions::DEBUG => $debug,
62
            'curl' => $curlOptions,
63
        ]);
64
    }
65
66
    /**
67
     * @param string $domainName
68
     * @param array  $options
69
     *
70
     * @throws \GuzzleHttp\Exception\GuzzleException
71
     *
72
     * @return \Nelexa\Doh\Storage\DnsRecord|null
73
     *
74
     * @psalm-suppress InvalidThrow
75
     */
76 13
    public function resolveDomain(string $domainName, array $options): ?DnsRecord
77
    {
78 13
        $dnsRecord = $this->findDnsRecordInStorage($domainName, $options);
79
80 13
        if ($dnsRecord === null) {
81 13
            $dnsMessage = $this->doDnsRequest($domainName);
82 13
            $dnsRecords = $this->indexingDnsRecords($dnsMessage);
83
            /** @var \DateInterval|int|string|null $defaultTtl */
84 13
            $defaultTtl = $options[DohMiddleware::OPTION_DOH_TTL] ?? null;
85 13
            $savedRecords = $this->saveDnsRecords($dnsRecords, $defaultTtl);
86 13
            $dnsRecord = $savedRecords[$domainName] ?? null;
87
88 13
            while ($dnsRecord !== null && $dnsRecord->isCnameRecord()) {
89 3
                foreach ($dnsRecord->getData() as $cnameDomain) {
90 3
                    if ($cnameDomain !== $domainName) { // infinite loop protection
91 3
                        $dnsRecord = $savedRecords[$cnameDomain] ?? null;
92
                    }
93
                }
94
            }
95
        }
96
97 13
        return $dnsRecord;
98
    }
99
100
    /**
101
     * @param string $domainName
102
     * @param array  $options
103
     *
104
     * @throws \GuzzleHttp\Exception\GuzzleException
105
     *
106
     * @psalm-suppress InvalidThrow
107
     *
108
     * @return \Nelexa\Doh\Storage\DnsRecord|null
109
     */
110 13
    private function findDnsRecordInStorage(string $domainName, array $options): ?DnsRecord
111
    {
112 13
        $dnsRecord = $this->storage->get($domainName);
113
114 13
        if ($dnsRecord !== null) {
115
            // resolve cname record
116 3
            if ($dnsRecord->isCnameRecord()) {
117 3
                $cnameDomains = $dnsRecord->getData();
118 3
                $dnsRecord = null;
119
120 3
                foreach ($cnameDomains as $cnameDomain) {
121 3
                    if ($cnameDomain !== $domainName) { // infinite loop protection
122 3
                        $dnsRecord = $this->resolveDomain($cnameDomain, $options);
123
124 3
                        if ($dnsRecord !== null) {
125 3
                            break;
126
                        }
127
                    }
128
                }
129
            }
130
131 3
            if ($dnsRecord !== null) {
132 3
                $dnsRecord->cacheHit = true;
133
            }
134
        }
135
136 13
        return $dnsRecord;
137
    }
138
139
    /**
140
     * @param \LibDNS\Messages\Message $dnsMessage
141
     *
142
     * @return array<string, array<int, array{data?: list<string>, ttl?: list<int>}>>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, array<int,...ng>, ttl?: list<int>}>> at position 12 could not be parsed: Expected '}' at position 12, but found 'list'.
Loading history...
143
     */
144 13
    private function indexingDnsRecords(Message $dnsMessage): array
145
    {
146 13
        $entries = [];
147
148
        /** @var Record $answerRecord */
149 13
        foreach ($dnsMessage->getAnswerRecords() as $answerRecord) {
150 13
            if ($answerRecord instanceof Resource) {
151 13
                $answerDomainName = (string) $answerRecord->getName()->getValue();
152 13
                $resourceType = $answerRecord->getType();
153
154
                if (
155 13
                    $resourceType === ResourceTypes::A
156 3
                    || $resourceType === ResourceTypes::AAAA
157 13
                    || $resourceType === ResourceTypes::CNAME
158
                ) {
159
                    /** @var \LibDNS\Records\Types\IPv4Address|\LibDNS\Records\Types\IPv6Address $dataField */
160 13
                    foreach ($answerRecord->getData() as $dataField) {
161 13
                        $entries[$answerDomainName][$resourceType]['data'][] = (string) $dataField;
162
                    }
163
164 13
                    $entries[$answerDomainName][$resourceType]['ttl'][] = $answerRecord->getTTL();
165
                }
166
            }
167
        }
168
169 13
        return $entries;
170
    }
171
172
    /**
173
     * @param string $domainName
174
     *
175
     * @throws \GuzzleHttp\Exception\GuzzleException
176
     *
177
     * @psalm-suppress InvalidThrow
178
     *
179
     * @return \LibDNS\Messages\Message
180
     */
181 13
    private function doDnsRequest(string $domainName): Message
182
    {
183 13
        $dnsQuery = self::encodeRequest(self::generateDnsQuery($domainName));
184 13
        $serverUrl = $this->dohServers[array_rand($this->dohServers)];
185
186 13
        $rawContents = $this->client
187 13
            ->request('GET', $serverUrl, [
188
                RequestOptions::QUERY => [
189
                    'dns' => $dnsQuery,
190
                ],
191
            ])
192 13
            ->getBody()
193 13
            ->getContents()
194
        ;
195
196 13
        return (new DecoderFactory())->create()->decode($rawContents);
197
    }
198
199 13
    private static function generateDnsQuery(string $domainName): string
200
    {
201 13
        $encodedDomainName = implode(
202
            '',
203 13
            array_map(static function (string $domainBit) {
204 13
                return \chr(\strlen($domainBit)) . $domainBit;
205 13
            }, explode('.', $domainName))
206
        );
207
208
        return "\xab\xcd"
209
            . \chr(1) . \chr(0)
210
            . \chr(0) . \chr(1)  // qdc
211
            . \chr(0) . \chr(0)  // anc
212
            . \chr(0) . \chr(0)  // nsc
213
            . \chr(0) . \chr(0)  // arc
214 13
            . $encodedDomainName . \chr(0) // domain name
215 13
            . \chr(0) . \chr(ResourceTypes::A) // resource type
216 13
            . \chr(0) . \chr(1);  // qclass
217
    }
218
219 13
    private static function encodeRequest(string $request): string
220
    {
221 13
        return str_replace('=', '', strtr(base64_encode($request), '+/', '-_'));
222
    }
223
224
    /**
225
     * @param array<string, array<int, array{data?: list<string>, ttl?: list<int>}>> $entries
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, array<int,...ng>, ttl?: list<int>}>> at position 12 could not be parsed: Expected '}' at position 12, but found 'list'.
Loading history...
226
     * @param \DateInterval|int|string|null                                          $defaultTtl
227
     *
228
     * @return array<string, DnsRecord>
229
     */
230 13
    private function saveDnsRecords(array $entries, $defaultTtl): array
231
    {
232 13
        $storages = [];
233 13
        foreach ($entries as $answerDomainName => $answerEntry) {
234 13
            foreach ($answerEntry as $resourceType => $entryValue) {
235
                /** @psalm-suppress ArgumentTypeCoercion */
236 13
                $ttl = $defaultTtl ?? max(10, min($entryValue['ttl'] ?? []));
237 13
                $dnsRecord = new DnsRecord($answerDomainName, $entryValue['data'] ?? [], $resourceType, $ttl);
238 13
                $storages[$answerDomainName] = $dnsRecord;
239 13
                $this->storage->save($answerDomainName, $dnsRecord);
240
            }
241
        }
242
243 13
        return $storages;
244
    }
245
}
246