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

DohMiddleware::isIpOrLocalDomainName()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 2
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 4
ccs 3
cts 3
cp 1
crap 2
rs 10
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