DohMiddleware::isSupportMultipleIps()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
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\Exception\GuzzleException;
17
use GuzzleHttp\Promise\PromiseInterface;
18
use Nelexa\Doh\Storage\DnsRecord;
19
use Nelexa\Doh\Storage\StorageFactory;
20
use Nelexa\Doh\Storage\StorageInterface;
21
use Psr\Http\Message\RequestInterface;
22
use Psr\Http\Message\ResponseInterface;
23
use Psr\Log\LoggerInterface;
24
use Psr\Log\NullLogger;
25
26
class DohMiddleware
27
{
28
    public const OPTION_DOH_ENABLED = 'doh';
29
30
    public const OPTION_DOH_TTL = 'doh_ttl';
31
32
    public const OPTION_DOH_SHUFFLE = 'doh_shuffle';
33
34
    public const HEADER_RESPONSE_RESOLVED_IPS = 'X-DoH-Ips';
35
36
    public const HEADER_RESPONSE_CACHE_HIT = 'X-DoH-Cache-Hit';
37
38
    public const HEADER_RESPONSE_CACHE_TTL = 'X-DoH-Cache-TTL';
39
40
    private const DEFAULT_OPTIONS = [
41
        self::OPTION_DOH_ENABLED => true,
42
        self::OPTION_DOH_TTL => null,
43
        self::OPTION_DOH_SHUFFLE => false,
44
    ];
45
46
    /** @var \Closure(\Psr\Http\Message\RequestInterface, array): \GuzzleHttp\Promise\PromiseInterface */
47
    private $nextHandler;
48
49
    /** @var \Nelexa\Doh\DomainResolver */
50
    private $domainResolver;
51
52
    /** @var \Psr\Log\LoggerInterface */
53
    private $logger;
54
55
    /**
56
     * @psalm-param \Closure(\Psr\Http\Message\RequestInterface, array $options): \GuzzleHttp\Promise\PromiseInterface $nextHandler
57
     *
58
     * @param \Closure                             $nextHandler
59
     * @param \Nelexa\Doh\Storage\StorageInterface $storage
60
     * @param string[]                             $dohServers
61
     * @param \Psr\Log\LoggerInterface             $logger
62
     * @param bool                                 $debug
63
     */
64 14
    private function __construct(
65
        \Closure $nextHandler,
66
        StorageInterface $storage,
67
        array $dohServers,
68
        LoggerInterface $logger,
69
        bool $debug = false
70
    ) {
71 14
        $this->nextHandler = $nextHandler;
72 14
        $this->domainResolver = new DomainResolver($storage, $dohServers, $debug);
73 14
        $this->logger = $logger;
74
    }
75
76
    /**
77
     * @param RequestInterface $request
78
     * @param array            $options
79
     *
80
     * @return PromiseInterface
81
     */
82 14
    public function __invoke(RequestInterface $request, array $options): PromiseInterface
83
    {
84 14
        $options += self::DEFAULT_OPTIONS;
85 14
        $domainName = $request->getUri()->getHost();
86
87
        if (
88 14
            !\function_exists('curl_init')
89 14
            || $options[self::OPTION_DOH_ENABLED] === false
90 14
            || $this->isIpOrLocalDomainName($domainName)
91
        ) {
92 1
            return ($this->nextHandler)($request, $options);
93
        }
94
95 13
        $dnsRecord = $this->resolve($domainName, $options);
96
97 13
        if ($dnsRecord !== null && $dnsRecord->count() > 0) {
98 13
            return $this->appendDnsRecord($request, $options, $dnsRecord);
99
        }
100
101
        $this->logger->warning(sprintf('DoH client could not resolve ip addresses for %s domain', $domainName));
102
103
        return ($this->nextHandler)($request, $options);
104
    }
105
106
    /**
107
     * @param \Psr\SimpleCache\CacheInterface|\Psr\Cache\CacheItemPoolInterface|StorageInterface|null $cache
108
     * @param string[]                                                                                $dohServers
109
     * @param \Psr\Log\LoggerInterface|null                                                           $logger
110
     * @param bool                                                                                    $debug
111
     *
112
     * @return callable
113
     */
114 14
    public static function create(
115
        $cache = null,
116
        array $dohServers = [],
117
        ?LoggerInterface $logger = null,
118
        bool $debug = false
119
    ): callable {
120 14
        $storage = StorageFactory::create($cache);
121
122 14
        return static function (\Closure $handler) use ($storage, $dohServers, $logger, $debug) {
123
            /** @psalm-var \Closure(\Psr\Http\Message\RequestInterface, array $options): \GuzzleHttp\Promise\PromiseInterface $handler */
124 14
            return new self($handler, $storage, $dohServers, $logger ?? new NullLogger(), $debug);
125
        };
126
    }
127
128 13
    private function resolve(string $domainName, array $options): ?DnsRecord
129
    {
130
        /** @psalm-suppress InvalidCatch */
131
        try {
132 13
            return $this->domainResolver->resolveDomain($domainName, $options);
133
        } catch (GuzzleException $e) {
134
            $this->logger->error(
135
                sprintf('[DoH] Error resolving %s domain', $domainName),
136
                [
137
                    'domain' => $domainName,
138
                    'exception' => $e,
139
                ]
140
            );
141
        }
142
143
        return null;
144
    }
145
146 13
    private function appendDnsRecord(RequestInterface $request, array $options, DnsRecord $dnsRecord): PromiseInterface
147
    {
148
        /** @var array<int, mixed> $curlOptions */
149
        $curlOptions = [
150
            \CURLOPT_DNS_USE_GLOBAL_CACHE => false, // disable global cache
151
        ];
152
153 13
        $ipAddresses = $dnsRecord->getData();
154
155 13
        if ($options[self::OPTION_DOH_SHUFFLE]) {
156
            if ($this->isSupportShuffleIps()) {
157
                /**
158
                 * @noinspection PhpElementIsNotAvailableInCurrentPhpVersionInspection
159
                 * @psalm-suppress UndefinedConstant
160
                 */
161
                $curlOptions[\CURLOPT_DNS_SHUFFLE_ADDRESSES] = true;
162
            } else {
163
                shuffle($ipAddresses);
164
            }
165
        }
166
167 13
        if (!$this->isSupportMultipleIps()) {
168 13
            $ipAddresses = \array_slice($ipAddresses, 0, 1);
169
        }
170
171 13
        $domainName = $dnsRecord->getDomainName();
172 13
        $ipAddressesString = implode(',', $ipAddresses);
173 13
        $curlOptions[\CURLOPT_RESOLVE] = [
174 13
            $domainName . ':80:' . $ipAddressesString,
175 13
            $domainName . ':443:' . $ipAddressesString,
176
        ];
177
178 13
        $port = $request->getUri()->getPort();
179
180 13
        if ($port !== null && $port !== 80 && $port !== 443) {
181
            $curlOptions[\CURLOPT_RESOLVE][] = $domainName . ':' . $port . ':' . $ipAddressesString;
182
        }
183
184 13
        $this->logger->debug(sprintf('[DoH] Set ip addresses %s for domain %s', $ipAddressesString, $domainName));
185
186 13
        $options['curl'] = $curlOptions;
187
188 13
        return ($this->nextHandler)($request, $options)->then(
189 13
            static function (ResponseInterface $response) use ($ipAddressesString, $dnsRecord) {
190 13
                if ($dnsRecord->cacheHit) {
191 3
                    $cacheTtl = max(0, $dnsRecord->getExpiredAt()->getTimestamp() - time());
192 3
                    $response = $response->withHeader(self::HEADER_RESPONSE_CACHE_TTL, (string) $cacheTtl);
193
                }
194
195
                return $response
196 13
                    ->withHeader(self::HEADER_RESPONSE_CACHE_HIT, var_export($dnsRecord->cacheHit, true))
197 13
                    ->withHeader(self::HEADER_RESPONSE_RESOLVED_IPS, $ipAddressesString)
198
                ;
199
            }
200
        );
201
    }
202
203
    /**
204
     * @see https://curl.haxx.se/libcurl/c/CURLOPT_RESOLVE.html Support for providing multiple IP
205
     *                                                          addresses per entry was added in 7.59.0.
206
     *
207
     * @return bool
208
     */
209 13
    private function isSupportMultipleIps(): bool
210
    {
211 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...
212
    }
213
214
    private function isSupportShuffleIps(): bool
215
    {
216
        return \PHP_VERSION_ID >= 70300
217
            && version_compare(
218
                (string) curl_version()['version'],
219
                '7.60.0',
220
                '>='
221
            );
222
    }
223
224 13
    private function isIpOrLocalDomainName(string $domainName): bool
225
    {
226 13
        return filter_var($domainName, \FILTER_VALIDATE_IP)
227 13
            || strcasecmp($domainName, 'localhost') === 0;
228
    }
229
}
230