Passed
Push — main ( 7da3b9...7b027b )
by Alexey
03:01
created

DohMiddleware::resolveDomain()   F

Complexity

Conditions 19
Paths 365

Size

Total Lines 76
Code Lines 40

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 26.5076

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 19
eloc 40
c 1
b 0
f 0
nc 365
nop 2
dl 0
loc 76
ccs 29
cts 40
cp 0.725
crap 26.5076
rs 1.6208

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\Exception\GuzzleException;
18
use GuzzleHttp\RequestOptions;
19
use LibDNS\Decoder\DecoderFactory;
20
use LibDNS\Messages\Message;
21
use LibDNS\Records\Record;
22
use LibDNS\Records\Resource;
23
use LibDNS\Records\ResourceTypes;
24
use Nelexa\Doh\Storage\StorageFactory;
25
use Nelexa\Doh\Storage\StorageInterface;
26
use Nelexa\Doh\Storage\StorageItem;
27
use Psr\Http\Message\RequestInterface;
28
use Psr\Http\Message\ResponseInterface;
29
use Psr\Log\LoggerInterface;
30
31
class DohMiddleware
32
{
33
    public const OPTION_DOH_ENABLED = 'doh';
34
35
    public const OPTION_DOH_TTL = 'doh_ttl';
36
37
    public const OPTION_DOH_SHUFFLE = 'doh_shuffle';
38
39
    public const HEADER_RESPONSE_RESOLVED_IPS = 'X-DoH-Ips';
40
41
    public const HEADER_RESPONSE_CACHE_HIT = 'X-DoH-Cache-Hit';
42
43
    public const HEADER_RESPONSE_CACHE_TTL = 'X-DoH-Cache-TTL';
44
45
    private const DEFAULT_OPTIONS = [
46
        self::OPTION_DOH_ENABLED => true,
47
        self::OPTION_DOH_TTL => null,
48
        self::OPTION_DOH_SHUFFLE => false,
49
    ];
50
51
    /** @var \Nelexa\Doh\Storage\StorageInterface */
52
    private $storage;
53
54
    /** @var string[] */
55
    private $dohServers;
56
57
    /** @var \Psr\Log\LoggerInterface|null */
58
    private $logger;
59
60
    /** @var callable */
61
    private $nextHandler;
62
63
    /** @var bool */
64
    private $debug;
65
66
    /**
67
     * @param callable                             $nextHandler
68
     * @param \Nelexa\Doh\Storage\StorageInterface $storage
69
     * @param string[]                             $dohServers
70
     * @param \Psr\Log\LoggerInterface|null        $logger
71
     * @param bool                                 $debug
72
     */
73 14
    private function __construct(
74
        callable $nextHandler,
75
        StorageInterface $storage,
76
        array $dohServers,
77
        ?LoggerInterface $logger = null,
78
        bool $debug = false
79
    ) {
80 14
        $this->nextHandler = $nextHandler;
81 14
        $this->storage = $storage;
82 14
        $this->dohServers = $dohServers;
83 14
        $this->logger = $logger;
84 14
        $this->debug = $debug;
85
    }
86
87
    /**
88
     * @param RequestInterface $request
89
     * @param array            $options
90
     *
91
     * @return mixed
92
     */
93 14
    public function __invoke(RequestInterface $request, array &$options)
94
    {
95 14
        $options += self::DEFAULT_OPTIONS;
96
97 14
        $handler = $this->nextHandler;
98 14
        $domainName = $request->getUri()->getHost();
99
100
        if (
101 14
            !\function_exists('curl_init')
102 14
            || $options[self::OPTION_DOH_ENABLED] === false
103 14
            || $this->isIpOrLocalDomainName($domainName)
104
        ) {
105 1
            return $handler($request, $options);
106
        }
107
108
        /** @psalm-suppress InvalidCatch */
109
        try {
110 13
            $resolvedItem = $this->resolveDomain($domainName, $options);
111
112 13
            if ($resolvedItem && $resolvedItem->count() > 0) {
113
                /** @var array<int, mixed> $curlOptions */
114
                $curlOptions = [
115
                    \CURLOPT_DNS_USE_GLOBAL_CACHE => false, // disable global cache
116
                ];
117
118 13
                $ipAddresses = $resolvedItem->getData();
119
120 13
                if ($options[self::OPTION_DOH_SHUFFLE]) {
121
                    if ($this->isSupportShuffleIps()) {
122
                        /**
123
                         * @noinspection PhpElementIsNotAvailableInCurrentPhpVersionInspection
124
                         * @psalm-suppress UndefinedConstant
125
                         */
126
                        $curlOptions[\CURLOPT_DNS_SHUFFLE_ADDRESSES] = true;
127
                    } else {
128
                        shuffle($ipAddresses);
129
                    }
130
                }
131
132 13
                if (!$this->isSupportMultipleIps()) {
133 13
                    $ipAddresses = \array_slice($ipAddresses, 0, 1);
134
                }
135
136 13
                $ipAddressesString = implode(',', $ipAddresses);
137 13
                $curlOptions[\CURLOPT_RESOLVE] = [
138 13
                    $domainName . ':80:' . $ipAddressesString,
139 13
                    $domainName . ':443:' . $ipAddressesString,
140
                ];
141
142 13
                $port = $request->getUri()->getPort();
143
144 13
                if ($port !== null && $port !== 80 && $port !== 443) {
145
                    $curlOptions[\CURLOPT_RESOLVE][] = $domainName . ':' . $port . ':' . $ipAddressesString;
146
                }
147
148 13
                if ($this->logger !== null) {
149 10
                    $this->logger->debug(
150 10
                        sprintf('DoH client adds resolving for %s', $domainName),
151
                        [
152
                            'domain' => $domainName,
153
                            'ipAddresses' => $ipAddresses,
154
                        ]
155
                    );
156
                }
157
158 13
                $options['curl'] = $curlOptions;
159
160
                /** @var \GuzzleHttp\Promise\PromiseInterface $promise */
161 13
                $promise = $handler($request, $options);
162
163 13
                return $promise->then(
164 13
                    static function (ResponseInterface $response) use ($ipAddressesString, $resolvedItem) {
165 13
                        if ($resolvedItem->cacheHit) {
166 3
                            $cacheTtl = max(0, $resolvedItem->getExpiredAt()->getTimestamp() - time());
167 3
                            $response = $response->withHeader(self::HEADER_RESPONSE_CACHE_TTL, (string) $cacheTtl);
168
                        }
169
170
                        return $response
171 13
                            ->withHeader(self::HEADER_RESPONSE_CACHE_HIT, var_export($resolvedItem->cacheHit, true))
172 13
                            ->withHeader(self::HEADER_RESPONSE_RESOLVED_IPS, $ipAddressesString)
173
                        ;
174
                    }
175
                );
176
            }
177
178
            if ($this->logger !== null) {
179
                $this->logger->warning(sprintf('DoH client could not resolve ip addresses for %s domain', $domainName));
180
            }
181
        } catch (GuzzleException $e) {
182
            if ($this->logger !== null) {
183
                $this->logger->error(
184
                    sprintf('Error DoH request for %s', $domainName),
185
                    [
186
                        'domain' => $domainName,
187
                        'exception' => $e,
188
                    ]
189
                );
190
            }
191
        }
192
193
        return $handler($request, $options);
194
    }
195
196
    /**
197
     * @param \Psr\SimpleCache\CacheInterface|\Psr\Cache\CacheItemPoolInterface|StorageInterface|null $cache
198
     * @param string[]                                                                                $dohServers
199
     * @param \Psr\Log\LoggerInterface|null                                                           $logger
200
     * @param bool                                                                                    $debug
201
     *
202
     * @return callable
203
     */
204 14
    public static function create(
205
        $cache = null,
206
        array $dohServers = [],
207
        ?LoggerInterface $logger = null,
208
        bool $debug = false
209
    ): callable {
210 14
        if (empty($dohServers)) {
211 4
            $dohServers = DohServers::DEFAULT_SERVERS;
212
        }
213
214 14
        $storage = StorageFactory::create($cache);
215
216 14
        return static function (callable $handler) use ($storage, $dohServers, $logger, $debug) {
217 14
            return new self($handler, $storage, $dohServers, $logger, $debug);
218
        };
219
    }
220
221
    /**
222
     * @see https://curl.haxx.se/libcurl/c/CURLOPT_RESOLVE.html Support for providing multiple IP
223
     *                                                          addresses per entry was added in 7.59.0.
224
     *
225
     * @return bool
226
     */
227 13
    private function isSupportMultipleIps(): bool
228
    {
229 13
        return version_compare((string) curl_version()['version'], '7.59.0', '>=');
0 ignored issues
show
Bug Best Practice introduced by
The expression return version_compare((...sion'], '7.59.0', '>=') could return the type integer which is incompatible with the type-hinted return boolean. Consider adding an additional type-check to rule them out.
Loading history...
230
    }
231
232
    private function isSupportShuffleIps(): bool
233
    {
234
        return \PHP_VERSION_ID >= 70300
235
            && version_compare(
236
                (string) curl_version()['version'],
237
                '7.60.0',
238
                '>='
239
            );
240
    }
241
242 13
    private function isIpOrLocalDomainName(string $domainName): bool
243
    {
244 13
        return filter_var($domainName, \FILTER_VALIDATE_IP)
245 13
            || strcasecmp($domainName, 'localhost') === 0;
246
    }
247
248
    /**
249
     * @param string $domainName
250
     * @param array  $options
251
     *
252
     * @throws \GuzzleHttp\Exception\GuzzleException
253
     *
254
     * @return \Nelexa\Doh\Storage\StorageItem|null
255
     */
256 13
    private function resolveDomain(string $domainName, array $options): ?StorageItem
257
    {
258 13
        $storageItem = $this->storage->get($domainName);
259
260 13
        if ($storageItem !== null) {
261
            // resolve cname record
262 3
            if ($storageItem->isCnameRecord()) {
263
                $cnameDomains = $storageItem->getData();
264
                $storageItem = null;
265
266
                foreach ($cnameDomains as $cnameDomain) {
267
                    if ($cnameDomain !== $domainName) { // infinite loop protection
268
                        $storageItem = $this->resolveDomain($cnameDomain, $options);
269
270
                        if ($storageItem !== null) {
271
                            break;
272
                        }
273
                    }
274
                }
275
            }
276
277 3
            if ($storageItem !== null) {
278 3
                $storageItem->cacheHit = true;
279
280 3
                return $storageItem;
281
            }
282
        }
283
284 13
        $dnsMessage = $this->doDnsRequest($domainName);
285
286 13
        $indexedAnswerEntries = [];
287
288
        /** @var Record $answerRecord */
289 13
        foreach ($dnsMessage->getAnswerRecords() as $answerRecord) {
290 13
            if ($answerRecord instanceof Resource) {
291 13
                $answerDomainName = (string) $answerRecord->getName()->getValue();
292 13
                $resourceType = $answerRecord->getType();
293
294
                if (
295 13
                        $resourceType === ResourceTypes::A
296
                        || $resourceType === ResourceTypes::AAAA
297 13
                        || $resourceType === ResourceTypes::CNAME
298
                ) {
299
                    /** @var \LibDNS\Records\Types\IPv4Address|\LibDNS\Records\Types\IPv6Address $dataField */
300 13
                    foreach ($answerRecord->getData() as $dataField) {
301 13
                        $indexedAnswerEntries[$answerDomainName][$resourceType]['data'][] = (string) $dataField;
302
                    }
303
304 13
                    $indexedAnswerEntries[$answerDomainName][$resourceType]['ttl'][] = $answerRecord->getTTL();
305
                }
306
            }
307
        }
308
309
        /** @var \DateInterval|int|string|null $defaultTtl */
310 13
        $defaultTtl = $options[self::OPTION_DOH_TTL] ?? null;
311 13
        $storages = [];
312 13
        foreach ($indexedAnswerEntries as $answerDomainName => $answerEntry) {
313 13
            foreach ($answerEntry as $resourceType => $entryValue) {
314 13
                $ttl = $defaultTtl ?? max(10, min($entryValue['ttl'] ?? [0]));
315 13
                $storageItem = new StorageItem($answerDomainName, $entryValue['data'] ?? [], $resourceType, $ttl);
316 13
                $storages[$answerDomainName] = $storageItem;
317 13
                $this->storage->save($answerDomainName, $storageItem);
318
            }
319
        }
320
321 13
        $storageItem = $storages[$domainName] ?? null;
322
323 13
        while ($storageItem !== null && $storageItem->isCnameRecord()) {
324
            foreach ($storageItem->getData() as $cnameDomain) {
325
                if ($cnameDomain !== $domainName) { // infinite loop protection
326
                    $storageItem = $storages[$cnameDomain] ?? null;
327
                }
328
            }
329
        }
330
331 13
        return $storageItem;
332
    }
333
334
    /**
335
     * @param string $domainName
336
     *
337
     * @throws \GuzzleHttp\Exception\GuzzleException
338
     *
339
     * @return \LibDNS\Messages\Message
340
     */
341 13
    private function doDnsRequest(string $domainName): Message
342
    {
343 13
        $dnsQuery = self::encodeRequest(self::generateDnsQuery($domainName));
344 13
        $serverUrl = $this->dohServers[array_rand($this->dohServers)];
345
346
        $requestOptions = [
347
            RequestOptions::HEADERS => [
348
                'Accept' => 'application/dns-udpwireformat, application/dns-message',
349
                'User-Agent' => 'DoH-Client',
350
            ],
351
            RequestOptions::CONNECT_TIMEOUT => 10.0,
352
            RequestOptions::TIMEOUT => 10.0,
353
            RequestOptions::VERSION => '2.0',
354 13
            RequestOptions::DEBUG => $this->debug,
355
            RequestOptions::QUERY => [
356
                'dns' => $dnsQuery,
357
            ],
358
        ];
359
360 13
        if (\defined('CURLOPT_IPRESOLVE') && \defined('CURL_IPRESOLVE_V4')) {
361 13
            $requestOptions['curl'][\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V4;
362
        }
363
364 13
        $rawContents = (new Client())
365 13
            ->request('GET', $serverUrl, $requestOptions)
366 13
            ->getBody()
367 13
            ->getContents()
368
        ;
369
370 13
        return (new DecoderFactory())->create()->decode($rawContents);
371
    }
372
373 13
    private static function generateDnsQuery(string $domainName): string
374
    {
375 13
        $encodedDomainName = implode('', array_map(static function (string $domainBit) {
376 13
            return \chr(\strlen($domainBit)) . $domainBit;
377 13
        }, explode('.', $domainName)));
378
379
        return "\xab\xcd"
380
            . \chr(1) . \chr(0)
381
            . \chr(0) . \chr(1)  // qdc
382
            . \chr(0) . \chr(0)  // anc
383
            . \chr(0) . \chr(0)  // nsc
384
            . \chr(0) . \chr(0)  // arc
385 13
            . $encodedDomainName . \chr(0) // domain name
386 13
            . \chr(0) . \chr(ResourceTypes::A) // resource type
387 13
            . \chr(0) . \chr(1);  // qclass
388
    }
389
390 13
    private static function encodeRequest(string $request): string
391
    {
392 13
        return str_replace('=', '', strtr(base64_encode($request), '+/', '-_'));
393
    }
394
}
395