├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── BadServerException.php ├── Config ├── Config.php └── HostsFile.php ├── Model ├── Message.php └── Record.php ├── Protocol ├── BinaryDumper.php └── Parser.php ├── Query ├── CachingExecutor.php ├── CancellationException.php ├── CoopExecutor.php ├── ExecutorInterface.php ├── FallbackExecutor.php ├── HostsFileExecutor.php ├── Query.php ├── RetryExecutor.php ├── SelectiveTransportExecutor.php ├── TcpTransportExecutor.php ├── TimeoutException.php ├── TimeoutExecutor.php └── UdpTransportExecutor.php ├── RecordNotFoundException.php └── Resolver ├── Factory.php ├── Resolver.php └── ResolverInterface.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.12.0 (2023-11-29) 4 | 5 | * Feature: Full PHP 8.3 compatibility. 6 | (#217 by @sergiy-petrov) 7 | 8 | * Update test environment and avoid unhandled promise rejections. 9 | (#215, #216 and #218 by @clue) 10 | 11 | ## 1.11.0 (2023-06-02) 12 | 13 | * Feature: Include timeout logic to avoid dependency on reactphp/promise-timer. 14 | (#213 by @clue) 15 | 16 | * Improve test suite and project setup and report failed assertions. 17 | (#210 by @clue, #212 by @WyriHaximus and #209 and #211 by @SimonFrings) 18 | 19 | ## 1.10.0 (2022-09-08) 20 | 21 | * Feature: Full support for PHP 8.2 release. 22 | (#201 by @clue and #207 by @WyriHaximus) 23 | 24 | * Feature: Optimize forward compatibility with Promise v3, avoid hitting autoloader. 25 | (#202 by @clue) 26 | 27 | * Feature / Fix: Improve error reporting when custom error handler is used. 28 | (#197 by @clue) 29 | 30 | * Fix: Fix invalid references in exception stack trace. 31 | (#191 by @clue) 32 | 33 | * Minor documentation improvements. 34 | (#195 by @SimonFrings and #203 by @nhedger) 35 | 36 | * Improve test suite, update to use default loop and new reactphp/async package. 37 | (#204, #205 and #206 by @clue and #196 by @SimonFrings) 38 | 39 | ## 1.9.0 (2021-12-20) 40 | 41 | * Feature: Full support for PHP 8.1 release and prepare PHP 8.2 compatibility 42 | by refactoring `Parser` to avoid assigning dynamic properties. 43 | (#188 and #186 by @clue and #184 by @SimonFrings) 44 | 45 | * Feature: Avoid dependency on `ext-filter`. 46 | (#185 by @clue) 47 | 48 | * Feature / Fix: Skip invalid nameserver entries from `resolv.conf` and ignore IPv6 zone IDs. 49 | (#187 by @clue) 50 | 51 | * Feature / Fix: Reduce socket read chunk size for queries over TCP/IP. 52 | (#189 by @clue) 53 | 54 | ## 1.8.0 (2021-07-11) 55 | 56 | A major new feature release, see [**release announcement**](https://clue.engineering/2021/announcing-reactphp-default-loop). 57 | 58 | * Feature: Simplify usage by supporting new [default loop](https://reactphp.org/event-loop/#loop). 59 | (#182 by @clue) 60 | 61 | ```php 62 | // old (still supported) 63 | $factory = new React\Dns\Resolver\Factory(); 64 | $resolver = $factory->create($config, $loop); 65 | 66 | // new (using default loop) 67 | $factory = new React\Dns\Resolver\Factory(); 68 | $resolver = $factory->create($config); 69 | ``` 70 | 71 | ## 1.7.0 (2021-06-25) 72 | 73 | * Feature: Update DNS `Factory` to accept complete `Config` object. 74 | Add new `FallbackExecutor` and use fallback DNS servers when `Config` lists multiple servers. 75 | (#179 and #180 by @clue) 76 | 77 | ```php 78 | // old (still supported) 79 | $config = React\Dns\Config\Config::loadSystemConfigBlocking(); 80 | $server = $config->nameservers ? reset($config->nameservers) : '8.8.8.8'; 81 | $resolver = $factory->create($server, $loop); 82 | 83 | // new 84 | $config = React\Dns\Config\Config::loadSystemConfigBlocking(); 85 | if (!$config->nameservers) { 86 | $config->nameservers[] = '8.8.8.8'; 87 | } 88 | $resolver = $factory->create($config, $loop); 89 | ``` 90 | 91 | ## 1.6.0 (2021-06-21) 92 | 93 | * Feature: Add support for legacy `SPF` record type. 94 | (#178 by @akondas and @clue) 95 | 96 | * Fix: Fix integer overflow for TCP/IP chunk size on 32 bit platforms. 97 | (#177 by @clue) 98 | 99 | ## 1.5.0 (2021-03-05) 100 | 101 | * Feature: Improve error reporting when query fails, include domain and query type and DNS server address where applicable. 102 | (#174 by @clue) 103 | 104 | * Feature: Improve error handling when sending data to DNS server fails (macOS). 105 | (#171 and #172 by @clue) 106 | 107 | * Fix: Improve DNS response parser to limit recursion for compressed labels. 108 | (#169 by @clue) 109 | 110 | * Improve test suite, use GitHub actions for continuous integration (CI). 111 | (#170 by @SimonFrings) 112 | 113 | ## 1.4.0 (2020-09-18) 114 | 115 | * Feature: Support upcoming PHP 8. 116 | (#168 by @clue) 117 | 118 | * Improve test suite and update to PHPUnit 9.3. 119 | (#164 by @clue, #165 and #166 by @SimonFrings and #167 by @WyriHaximus) 120 | 121 | ## 1.3.0 (2020-07-10) 122 | 123 | * Feature: Forward compatibility with react/promise v3. 124 | (#153 by @WyriHaximus) 125 | 126 | * Feature: Support parsing `OPT` records (EDNS0). 127 | (#157 by @clue) 128 | 129 | * Fix: Avoid PHP warnings due to lack of args in exception trace on PHP 7.4. 130 | (#160 by @clue) 131 | 132 | * Improve test suite and add `.gitattributes` to exclude dev files from exports. 133 | Run tests on PHPUnit 9 and PHP 7.4 and clean up test suite. 134 | (#154 by @reedy, #156 by @clue and #163 by @SimonFrings) 135 | 136 | ## 1.2.0 (2019-08-15) 137 | 138 | * Feature: Add `TcpTransportExecutor` to send DNS queries over TCP/IP connection, 139 | add `SelectiveTransportExecutor` to retry with TCP if UDP is truncated and 140 | automatically select transport protocol when no explicit `udp://` or `tcp://` scheme is given in `Factory`. 141 | (#145, #146, #147 and #148 by @clue) 142 | 143 | * Feature: Support escaping literal dots and special characters in domain names. 144 | (#144 by @clue) 145 | 146 | ## 1.1.0 (2019-07-18) 147 | 148 | * Feature: Support parsing `CAA` and `SSHFP` records. 149 | (#141 and #142 by @clue) 150 | 151 | * Feature: Add `ResolverInterface` as common interface for `Resolver` class. 152 | (#139 by @clue) 153 | 154 | * Fix: Add missing private property definitions and 155 | remove unneeded dependency on `react/stream`. 156 | (#140 and #143 by @clue) 157 | 158 | ## 1.0.0 (2019-07-11) 159 | 160 | * First stable LTS release, now following [SemVer](https://semver.org/). 161 | We'd like to emphasize that this component is production ready and battle-tested. 162 | We plan to support all long-term support (LTS) releases for at least 24 months, 163 | so you have a rock-solid foundation to build on top of. 164 | 165 | This update involves a number of BC breaks due to dropped support for 166 | deprecated functionality and some internal API cleanup. We've tried hard to 167 | avoid BC breaks where possible and minimize impact otherwise. We expect that 168 | most consumers of this package will actually not be affected by any BC 169 | breaks, see below for more details: 170 | 171 | * BC break: Delete all deprecated APIs, use `Query` objects for `Message` questions 172 | instead of nested arrays and increase code coverage to 100%. 173 | (#130 by @clue) 174 | 175 | * BC break: Move `$nameserver` from `ExecutorInterface` to `UdpTransportExecutor`, 176 | remove advanced/internal `UdpTransportExecutor` args for `Parser`/`BinaryDumper` and 177 | add API documentation for `ExecutorInterface`. 178 | (#135, #137 and #138 by @clue) 179 | 180 | * BC break: Replace `HeaderBag` attributes with simple `Message` properties. 181 | (#132 by @clue) 182 | 183 | * BC break: Mark all `Record` attributes as required, add documentation vs `Query`. 184 | (#136 by @clue) 185 | 186 | * BC break: Mark all classes as final to discourage inheritance 187 | (#134 by @WyriHaximus) 188 | 189 | ## 0.4.19 (2019-07-10) 190 | 191 | * Feature: Avoid garbage references when DNS resolution rejects on legacy PHP <= 5.6. 192 | (#133 by @clue) 193 | 194 | ## 0.4.18 (2019-09-07) 195 | 196 | * Feature / Fix: Implement `CachingExecutor` using cache TTL, deprecate old `CachedExecutor`, 197 | respect TTL from response records when caching and do not cache truncated responses. 198 | (#129 by @clue) 199 | 200 | * Feature: Limit cache size to 256 last responses by default. 201 | (#127 by @clue) 202 | 203 | * Feature: Cooperatively resolve hosts to avoid running same query concurrently. 204 | (#125 by @clue) 205 | 206 | ## 0.4.17 (2019-04-01) 207 | 208 | * Feature: Support parsing `authority` and `additional` records from DNS response. 209 | (#123 by @clue) 210 | 211 | * Feature: Support dumping records as part of outgoing binary DNS message. 212 | (#124 by @clue) 213 | 214 | * Feature: Forward compatibility with upcoming Cache v0.6 and Cache v1.0 215 | (#121 by @clue) 216 | 217 | * Improve test suite to add forward compatibility with PHPUnit 7, 218 | test against PHP 7.3 and use legacy PHPUnit 5 on legacy HHVM. 219 | (#122 by @clue) 220 | 221 | ## 0.4.16 (2018-11-11) 222 | 223 | * Feature: Improve promise cancellation for DNS lookup retries and clean up any garbage references. 224 | (#118 by @clue) 225 | 226 | * Fix: Reject parsing malformed DNS response messages such as incomplete DNS response messages, 227 | malformed record data or malformed compressed domain name labels. 228 | (#115 and #117 by @clue) 229 | 230 | * Fix: Fix interpretation of TTL as UINT32 with most significant bit unset. 231 | (#116 by @clue) 232 | 233 | * Fix: Fix caching advanced MX/SRV/TXT/SOA structures. 234 | (#112 by @clue) 235 | 236 | ## 0.4.15 (2018-07-02) 237 | 238 | * Feature: Add `resolveAll()` method to support custom query types in `Resolver`. 239 | (#110 by @clue and @WyriHaximus) 240 | 241 | ```php 242 | $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then(function ($ips) { 243 | echo 'IPv6 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; 244 | }); 245 | ``` 246 | 247 | * Feature: Support parsing `NS`, `TXT`, `MX`, `SOA` and `SRV` records. 248 | (#104, #105, #106, #107 and #108 by @clue) 249 | 250 | * Feature: Add support for `Message::TYPE_ANY` and parse unknown types as binary data. 251 | (#104 by @clue) 252 | 253 | * Feature: Improve error messages for failed queries and improve documentation. 254 | (#109 by @clue) 255 | 256 | * Feature: Add reverse DNS lookup example. 257 | (#111 by @clue) 258 | 259 | ## 0.4.14 (2018-06-26) 260 | 261 | * Feature: Add `UdpTransportExecutor`, validate incoming DNS response messages 262 | to avoid cache poisoning attacks and deprecate legacy `Executor`. 263 | (#101 and #103 by @clue) 264 | 265 | * Feature: Forward compatibility with Cache 0.5 266 | (#102 by @clue) 267 | 268 | * Deprecate legacy `Query::$currentTime` and binary parser data attributes to clean up and simplify API. 269 | (#99 by @clue) 270 | 271 | ## 0.4.13 (2018-02-27) 272 | 273 | * Add `Config::loadSystemConfigBlocking()` to load default system config 274 | and support parsing DNS config on all supported platforms 275 | (`/etc/resolv.conf` on Unix/Linux/Mac and WMIC on Windows) 276 | (#92, #93, #94 and #95 by @clue) 277 | 278 | ```php 279 | $config = Config::loadSystemConfigBlocking(); 280 | $server = $config->nameservers ? reset($config->nameservers) : '8.8.8.8'; 281 | ``` 282 | 283 | * Remove unneeded cyclic dependency on react/socket 284 | (#96 by @clue) 285 | 286 | ## 0.4.12 (2018-01-14) 287 | 288 | * Improve test suite by adding forward compatibility with PHPUnit 6, 289 | test against PHP 7.2, fix forward compatibility with upcoming EventLoop releases, 290 | add test group to skip integration tests relying on internet connection 291 | and add minor documentation improvements. 292 | (#85 and #87 by @carusogabriel, #88 and #89 by @clue and #83 by @jsor) 293 | 294 | ## 0.4.11 (2017-08-25) 295 | 296 | * Feature: Support resolving from default hosts file 297 | (#75, #76 and #77 by @clue) 298 | 299 | This means that resolving hosts such as `localhost` will now work as 300 | expected across all platforms with no changes required: 301 | 302 | ```php 303 | $resolver->resolve('localhost')->then(function ($ip) { 304 | echo 'IP: ' . $ip; 305 | }); 306 | ``` 307 | 308 | The new `HostsExecutor` exists for advanced usage and is otherwise used 309 | internally for this feature. 310 | 311 | ## 0.4.10 (2017-08-10) 312 | 313 | * Feature: Forward compatibility with EventLoop v1.0 and v0.5 and 314 | lock minimum dependencies and work around circular dependency for tests 315 | (#70 and #71 by @clue) 316 | 317 | * Fix: Work around DNS timeout issues for Windows users 318 | (#74 by @clue) 319 | 320 | * Documentation and examples for advanced usage 321 | (#66 by @WyriHaximus) 322 | 323 | * Remove broken TCP code, do not retry with invalid TCP query 324 | (#73 by @clue) 325 | 326 | * Improve test suite by fixing HHVM build for now again and ignore future HHVM build errors and 327 | lock Travis distro so new defaults will not break the build and 328 | fix failing tests for PHP 7.1 329 | (#68 by @WyriHaximus and #69 and #72 by @clue) 330 | 331 | ## 0.4.9 (2017-05-01) 332 | 333 | * Feature: Forward compatibility with upcoming Socket v1.0 and v0.8 334 | (#61 by @clue) 335 | 336 | ## 0.4.8 (2017-04-16) 337 | 338 | * Feature: Add support for the AAAA record type to the protocol parser 339 | (#58 by @othillo) 340 | 341 | * Feature: Add support for the PTR record type to the protocol parser 342 | (#59 by @othillo) 343 | 344 | ## 0.4.7 (2017-03-31) 345 | 346 | * Feature: Forward compatibility with upcoming Socket v0.6 and v0.7 component 347 | (#57 by @clue) 348 | 349 | ## 0.4.6 (2017-03-11) 350 | 351 | * Fix: Fix DNS timeout issues for Windows users and add forward compatibility 352 | with Stream v0.5 and upcoming v0.6 353 | (#53 by @clue) 354 | 355 | * Improve test suite by adding PHPUnit to `require-dev` 356 | (#54 by @clue) 357 | 358 | ## 0.4.5 (2017-03-02) 359 | 360 | * Fix: Ensure we ignore the case of the answer 361 | (#51 by @WyriHaximus) 362 | 363 | * Feature: Add `TimeoutExecutor` and simplify internal APIs to allow internal 364 | code re-use for upcoming versions. 365 | (#48 and #49 by @clue) 366 | 367 | ## 0.4.4 (2017-02-13) 368 | 369 | * Fix: Fix handling connection and stream errors 370 | (#45 by @clue) 371 | 372 | * Feature: Add examples and forward compatibility with upcoming Socket v0.5 component 373 | (#46 and #47 by @clue) 374 | 375 | ## 0.4.3 (2016-07-31) 376 | 377 | * Feature: Allow for cache adapter injection (#38 by @WyriHaximus) 378 | 379 | ```php 380 | $factory = new React\Dns\Resolver\Factory(); 381 | 382 | $cache = new MyCustomCacheInstance(); 383 | $resolver = $factory->createCached('8.8.8.8', $loop, $cache); 384 | ``` 385 | 386 | * Feature: Support Promise cancellation (#35 by @clue) 387 | 388 | ```php 389 | $promise = $resolver->resolve('reactphp.org'); 390 | 391 | $promise->cancel(); 392 | ``` 393 | 394 | ## 0.4.2 (2016-02-24) 395 | 396 | * Repository maintenance, split off from main repo, improve test suite and documentation 397 | * First class support for PHP7 and HHVM (#34 by @clue) 398 | * Adjust compatibility to 5.3 (#30 by @clue) 399 | 400 | ## 0.4.1 (2014-04-13) 401 | 402 | * Bug fix: Fixed PSR-4 autoload path (@marcj/WyriHaximus) 403 | 404 | ## 0.4.0 (2014-02-02) 405 | 406 | * BC break: Bump minimum PHP version to PHP 5.4, remove 5.3 specific hacks 407 | * BC break: Update to React/Promise 2.0 408 | * Bug fix: Properly resolve CNAME aliases 409 | * Dependency: Autoloading and filesystem structure now PSR-4 instead of PSR-0 410 | * Bump React dependencies to v0.4 411 | 412 | ## 0.3.2 (2013-05-10) 413 | 414 | * Feature: Support default port for IPv6 addresses (@clue) 415 | 416 | ## 0.3.0 (2013-04-14) 417 | 418 | * Bump React dependencies to v0.3 419 | 420 | ## 0.2.6 (2012-12-26) 421 | 422 | * Feature: New cache component, used by DNS 423 | 424 | ## 0.2.5 (2012-11-26) 425 | 426 | * Version bump 427 | 428 | ## 0.2.4 (2012-11-18) 429 | 430 | * Feature: Change to promise-based API (@jsor) 431 | 432 | ## 0.2.3 (2012-11-14) 433 | 434 | * Version bump 435 | 436 | ## 0.2.2 (2012-10-28) 437 | 438 | * Feature: DNS executor timeout handling (@arnaud-lb) 439 | * Feature: DNS retry executor (@arnaud-lb) 440 | 441 | ## 0.2.1 (2012-10-14) 442 | 443 | * Minor adjustments to DNS parser 444 | 445 | ## 0.2.0 (2012-09-10) 446 | 447 | * Feature: DNS resolver 448 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2012 Christian Lück, Cees-Jan Kiewiet, Jan Sorgalla, Chris Boden, Igor Wiedler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DNS 2 | 3 | [![CI status](https://github.com/reactphp/dns/actions/workflows/ci.yml/badge.svg)](https://github.com/reactphp/dns/actions) 4 | [![installs on Packagist](https://img.shields.io/packagist/dt/react/dns?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/react/dns) 5 | 6 | Async DNS resolver for [ReactPHP](https://reactphp.org/). 7 | 8 | > **Development version:** This branch contains the code for the upcoming v3 9 | > release. For the code of the current stable v1 release, check out the 10 | > [`1.x` branch](https://github.com/reactphp/dns/tree/1.x). 11 | > 12 | > The upcoming v3 release will be the way forward for this package. However, 13 | > we will still actively support v1 for those not yet on the latest version. 14 | > See also [installation instructions](#install) for more details. 15 | 16 | The main point of the DNS component is to provide async DNS resolution. 17 | However, it is really a toolkit for working with DNS messages, and could 18 | easily be used to create a DNS server. 19 | 20 | **Table of contents** 21 | 22 | * [Basic usage](#basic-usage) 23 | * [Caching](#caching) 24 | * [Custom cache adapter](#custom-cache-adapter) 25 | * [ResolverInterface](#resolverinterface) 26 | * [resolve()](#resolve) 27 | * [resolveAll()](#resolveall) 28 | * [Advanced usage](#advanced-usage) 29 | * [UdpTransportExecutor](#udptransportexecutor) 30 | * [TcpTransportExecutor](#tcptransportexecutor) 31 | * [SelectiveTransportExecutor](#selectivetransportexecutor) 32 | * [HostsFileExecutor](#hostsfileexecutor) 33 | * [Install](#install) 34 | * [Tests](#tests) 35 | * [License](#license) 36 | * [References](#references) 37 | 38 | ## Basic usage 39 | 40 | The most basic usage is to just create a resolver through the resolver 41 | factory. All you need to give it is a nameserver, then you can start resolving 42 | names, baby! 43 | 44 | ```php 45 | $config = React\Dns\Config\Config::loadSystemConfigBlocking(); 46 | if (!$config->nameservers) { 47 | $config->nameservers[] = '8.8.8.8'; 48 | } 49 | 50 | $factory = new React\Dns\Resolver\Factory(); 51 | $dns = $factory->create($config); 52 | 53 | $dns->resolve('igor.io')->then(function ($ip) { 54 | echo "Host: $ip\n"; 55 | }); 56 | ``` 57 | 58 | See also the [first example](examples). 59 | 60 | The `Config` class can be used to load the system default config. This is an 61 | operation that may access the filesystem and block. Ideally, this method should 62 | thus be executed only once before the loop starts and not repeatedly while it is 63 | running. 64 | Note that this class may return an *empty* configuration if the system config 65 | can not be loaded. As such, you'll likely want to apply a default nameserver 66 | as above if none can be found. 67 | 68 | > Note that the factory loads the hosts file from the filesystem once when 69 | creating the resolver instance. 70 | Ideally, this method should thus be executed only once before the loop starts 71 | and not repeatedly while it is running. 72 | 73 | But there's more. 74 | 75 | ## Caching 76 | 77 | You can cache results by configuring the resolver to use a `CachedExecutor`: 78 | 79 | ```php 80 | $config = React\Dns\Config\Config::loadSystemConfigBlocking(); 81 | if (!$config->nameservers) { 82 | $config->nameservers[] = '8.8.8.8'; 83 | } 84 | 85 | $factory = new React\Dns\Resolver\Factory(); 86 | $dns = $factory->createCached($config); 87 | 88 | $dns->resolve('igor.io')->then(function ($ip) { 89 | echo "Host: $ip\n"; 90 | }); 91 | 92 | ... 93 | 94 | $dns->resolve('igor.io')->then(function ($ip) { 95 | echo "Host: $ip\n"; 96 | }); 97 | ``` 98 | 99 | If the first call returns before the second, only one query will be executed. 100 | The second result will be served from an in memory cache. 101 | This is particularly useful for long running scripts where the same hostnames 102 | have to be looked up multiple times. 103 | 104 | See also the [third example](examples). 105 | 106 | ### Custom cache adapter 107 | 108 | By default, the above will use an in memory cache. 109 | 110 | You can also specify a custom cache implementing [`CacheInterface`](https://github.com/reactphp/cache) to handle the record cache instead: 111 | 112 | ```php 113 | $cache = new React\Cache\ArrayCache(); 114 | $factory = new React\Dns\Resolver\Factory(); 115 | $dns = $factory->createCached('8.8.8.8', null, $cache); 116 | ``` 117 | 118 | See also the wiki for possible [cache implementations](https://github.com/reactphp/react/wiki/Users#cache-implementations). 119 | 120 | ## ResolverInterface 121 | 122 | 123 | 124 | ### resolve() 125 | 126 | The `resolve(string $domain): PromiseInterface` method can be used to 127 | resolve the given $domain name to a single IPv4 address (type `A` query). 128 | 129 | ```php 130 | $resolver->resolve('reactphp.org')->then(function ($ip) { 131 | echo 'IP for reactphp.org is ' . $ip . PHP_EOL; 132 | }); 133 | ``` 134 | 135 | This is one of the main methods in this package. It sends a DNS query 136 | for the given $domain name to your DNS server and returns a single IP 137 | address on success. 138 | 139 | If the DNS server sends a DNS response message that contains more than 140 | one IP address for this query, it will randomly pick one of the IP 141 | addresses from the response. If you want the full list of IP addresses 142 | or want to send a different type of query, you should use the 143 | [`resolveAll()`](#resolveall) method instead. 144 | 145 | If the DNS server sends a DNS response message that indicates an error 146 | code, this method will reject with a `RecordNotFoundException`. Its 147 | message and code can be used to check for the response code. 148 | 149 | If the DNS communication fails and the server does not respond with a 150 | valid response message, this message will reject with an `Exception`. 151 | 152 | Pending DNS queries can be cancelled by cancelling its pending promise like so: 153 | 154 | ```php 155 | $promise = $resolver->resolve('reactphp.org'); 156 | 157 | $promise->cancel(); 158 | ``` 159 | 160 | ### resolveAll() 161 | 162 | The `resolveAll(string $host, int $type): PromiseInterface` method can be used to 163 | resolve all record values for the given $domain name and query $type. 164 | 165 | ```php 166 | $resolver->resolveAll('reactphp.org', Message::TYPE_A)->then(function ($ips) { 167 | echo 'IPv4 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; 168 | }); 169 | 170 | $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then(function ($ips) { 171 | echo 'IPv6 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; 172 | }); 173 | ``` 174 | 175 | This is one of the main methods in this package. It sends a DNS query 176 | for the given $domain name to your DNS server and returns a list with all 177 | record values on success. 178 | 179 | If the DNS server sends a DNS response message that contains one or more 180 | records for this query, it will return a list with all record values 181 | from the response. You can use the `Message::TYPE_*` constants to control 182 | which type of query will be sent. Note that this method always returns a 183 | list of record values, but each record value type depends on the query 184 | type. For example, it returns the IPv4 addresses for type `A` queries, 185 | the IPv6 addresses for type `AAAA` queries, the hostname for type `NS`, 186 | `CNAME` and `PTR` queries and structured data for other queries. See also 187 | the `Record` documentation for more details. 188 | 189 | If the DNS server sends a DNS response message that indicates an error 190 | code, this method will reject with a `RecordNotFoundException`. Its 191 | message and code can be used to check for the response code. 192 | 193 | If the DNS communication fails and the server does not respond with a 194 | valid response message, this message will reject with an `Exception`. 195 | 196 | Pending DNS queries can be cancelled by cancelling its pending promise like so: 197 | 198 | ```php 199 | $promise = $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA); 200 | 201 | $promise->cancel(); 202 | ``` 203 | 204 | ## Advanced Usage 205 | 206 | ### UdpTransportExecutor 207 | 208 | The `UdpTransportExecutor` can be used to 209 | send DNS queries over a UDP transport. 210 | 211 | This is the main class that sends a DNS query to your DNS server and is used 212 | internally by the `Resolver` for the actual message transport. 213 | 214 | For more advanced usages one can utilize this class directly. 215 | The following example looks up the `IPv6` address for `igor.io`. 216 | 217 | ```php 218 | $executor = new UdpTransportExecutor('8.8.8.8:53'); 219 | 220 | $executor->query( 221 | new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) 222 | )->then(function (Message $message) { 223 | foreach ($message->answers as $answer) { 224 | echo 'IPv6: ' . $answer->data . PHP_EOL; 225 | } 226 | }, 'printf'); 227 | ``` 228 | 229 | See also the [fourth example](examples). 230 | 231 | Note that this executor does not implement a timeout, so you will very likely 232 | want to use this in combination with a `TimeoutExecutor` like this: 233 | 234 | ```php 235 | $executor = new TimeoutExecutor( 236 | new UdpTransportExecutor($nameserver), 237 | 3.0 238 | ); 239 | ``` 240 | 241 | Also note that this executor uses an unreliable UDP transport and that it 242 | does not implement any retry logic, so you will likely want to use this in 243 | combination with a `RetryExecutor` like this: 244 | 245 | ```php 246 | $executor = new RetryExecutor( 247 | new TimeoutExecutor( 248 | new UdpTransportExecutor($nameserver), 249 | 3.0 250 | ) 251 | ); 252 | ``` 253 | 254 | Note that this executor is entirely async and as such allows you to execute 255 | any number of queries concurrently. You should probably limit the number of 256 | concurrent queries in your application or you're very likely going to face 257 | rate limitations and bans on the resolver end. For many common applications, 258 | you may want to avoid sending the same query multiple times when the first 259 | one is still pending, so you will likely want to use this in combination with 260 | a `CoopExecutor` like this: 261 | 262 | ```php 263 | $executor = new CoopExecutor( 264 | new RetryExecutor( 265 | new TimeoutExecutor( 266 | new UdpTransportExecutor($nameserver), 267 | 3.0 268 | ) 269 | ) 270 | ); 271 | ``` 272 | 273 | > Internally, this class uses PHP's UDP sockets and does not take advantage 274 | of [react/datagram](https://github.com/reactphp/datagram) purely for 275 | organizational reasons to avoid a cyclic dependency between the two 276 | packages. Higher-level components should take advantage of the Datagram 277 | component instead of reimplementing this socket logic from scratch. 278 | 279 | ### TcpTransportExecutor 280 | 281 | The `TcpTransportExecutor` class can be used to 282 | send DNS queries over a TCP/IP stream transport. 283 | 284 | This is one of the main classes that send a DNS query to your DNS server. 285 | 286 | For more advanced usages one can utilize this class directly. 287 | The following example looks up the `IPv6` address for `reactphp.org`. 288 | 289 | ```php 290 | $executor = new TcpTransportExecutor('8.8.8.8:53'); 291 | 292 | $executor->query( 293 | new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) 294 | )->then(function (Message $message) { 295 | foreach ($message->answers as $answer) { 296 | echo 'IPv6: ' . $answer->data . PHP_EOL; 297 | } 298 | }, 'printf'); 299 | ``` 300 | 301 | See also [example #92](examples). 302 | 303 | Note that this executor does not implement a timeout, so you will very likely 304 | want to use this in combination with a `TimeoutExecutor` like this: 305 | 306 | ```php 307 | $executor = new TimeoutExecutor( 308 | new TcpTransportExecutor($nameserver), 309 | 3.0 310 | ); 311 | ``` 312 | 313 | Unlike the `UdpTransportExecutor`, this class uses a reliable TCP/IP 314 | transport, so you do not necessarily have to implement any retry logic. 315 | 316 | Note that this executor is entirely async and as such allows you to execute 317 | queries concurrently. The first query will establish a TCP/IP socket 318 | connection to the DNS server which will be kept open for a short period. 319 | Additional queries will automatically reuse this existing socket connection 320 | to the DNS server, will pipeline multiple requests over this single 321 | connection and will keep an idle connection open for a short period. The 322 | initial TCP/IP connection overhead may incur a slight delay if you only send 323 | occasional queries – when sending a larger number of concurrent queries over 324 | an existing connection, it becomes increasingly more efficient and avoids 325 | creating many concurrent sockets like the UDP-based executor. You may still 326 | want to limit the number of (concurrent) queries in your application or you 327 | may be facing rate limitations and bans on the resolver end. For many common 328 | applications, you may want to avoid sending the same query multiple times 329 | when the first one is still pending, so you will likely want to use this in 330 | combination with a `CoopExecutor` like this: 331 | 332 | ```php 333 | $executor = new CoopExecutor( 334 | new TimeoutExecutor( 335 | new TcpTransportExecutor($nameserver), 336 | 3.0 337 | ) 338 | ); 339 | ``` 340 | 341 | > Internally, this class uses PHP's TCP/IP sockets and does not take advantage 342 | of [react/socket](https://github.com/reactphp/socket) purely for 343 | organizational reasons to avoid a cyclic dependency between the two 344 | packages. Higher-level components should take advantage of the Socket 345 | component instead of reimplementing this socket logic from scratch. 346 | 347 | ### SelectiveTransportExecutor 348 | 349 | The `SelectiveTransportExecutor` class can be used to 350 | Send DNS queries over a UDP or TCP/IP stream transport. 351 | 352 | This class will automatically choose the correct transport protocol to send 353 | a DNS query to your DNS server. It will always try to send it over the more 354 | efficient UDP transport first. If this query yields a size related issue 355 | (truncated messages), it will retry over a streaming TCP/IP transport. 356 | 357 | For more advanced usages one can utilize this class directly. 358 | The following example looks up the `IPv6` address for `reactphp.org`. 359 | 360 | ```php 361 | $executor = new SelectiveTransportExecutor($udpExecutor, $tcpExecutor); 362 | 363 | $executor->query( 364 | new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) 365 | )->then(function (Message $message) { 366 | foreach ($message->answers as $answer) { 367 | echo 'IPv6: ' . $answer->data . PHP_EOL; 368 | } 369 | }, 'printf'); 370 | ``` 371 | 372 | Note that this executor only implements the logic to select the correct 373 | transport for the given DNS query. Implementing the correct transport logic, 374 | implementing timeouts and any retry logic is left up to the given executors, 375 | see also [`UdpTransportExecutor`](#udptransportexecutor) and 376 | [`TcpTransportExecutor`](#tcptransportexecutor) for more details. 377 | 378 | Note that this executor is entirely async and as such allows you to execute 379 | any number of queries concurrently. You should probably limit the number of 380 | concurrent queries in your application or you're very likely going to face 381 | rate limitations and bans on the resolver end. For many common applications, 382 | you may want to avoid sending the same query multiple times when the first 383 | one is still pending, so you will likely want to use this in combination with 384 | a `CoopExecutor` like this: 385 | 386 | ```php 387 | $executor = new CoopExecutor( 388 | new SelectiveTransportExecutor( 389 | $datagramExecutor, 390 | $streamExecutor 391 | ) 392 | ); 393 | ``` 394 | 395 | ### HostsFileExecutor 396 | 397 | Note that the above `UdpTransportExecutor` class always performs an actual DNS query. 398 | If you also want to take entries from your hosts file into account, you may 399 | use this code: 400 | 401 | ```php 402 | $hosts = \React\Dns\Config\HostsFile::loadFromPathBlocking(); 403 | 404 | $executor = new UdpTransportExecutor('8.8.8.8:53'); 405 | $executor = new HostsFileExecutor($hosts, $executor); 406 | 407 | $executor->query( 408 | new Query('localhost', Message::TYPE_A, Message::CLASS_IN) 409 | ); 410 | ``` 411 | 412 | ## Install 413 | 414 | The recommended way to install this library is [through Composer](https://getcomposer.org/). 415 | [New to Composer?](https://getcomposer.org/doc/00-intro.md) 416 | 417 | Once released, this project will follow [SemVer](https://semver.org/). 418 | At the moment, this will install the latest development version: 419 | 420 | ```bash 421 | composer require react/dns:^3@dev 422 | ``` 423 | 424 | See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. 425 | 426 | This project aims to run on any platform and thus does not require any PHP 427 | extensions and supports running on PHP 7.1 through current PHP 8+. 428 | It's *highly recommended to use the latest supported PHP version* for this project. 429 | 430 | ## Tests 431 | 432 | To run the test suite, you first need to clone this repo and then install all 433 | dependencies [through Composer](https://getcomposer.org/): 434 | 435 | ```bash 436 | composer install 437 | ``` 438 | 439 | To run the test suite, go to the project root and run: 440 | 441 | ```bash 442 | vendor/bin/phpunit 443 | ``` 444 | 445 | The test suite also contains a number of functional integration tests that rely 446 | on a stable internet connection. 447 | If you do not want to run these, they can simply be skipped like this: 448 | 449 | ```bash 450 | vendor/bin/phpunit --exclude-group internet 451 | ``` 452 | 453 | ## License 454 | 455 | MIT, see [LICENSE file](LICENSE). 456 | 457 | ## References 458 | 459 | * [RFC 1034](https://tools.ietf.org/html/rfc1034) Domain Names - Concepts and Facilities 460 | * [RFC 1035](https://tools.ietf.org/html/rfc1035) Domain Names - Implementation and Specification 461 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react/dns", 3 | "description": "Async DNS resolver for ReactPHP", 4 | "keywords": ["dns", "dns-resolver", "ReactPHP", "async"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Christian Lück", 9 | "homepage": "https://clue.engineering/", 10 | "email": "christian@clue.engineering" 11 | }, 12 | { 13 | "name": "Cees-Jan Kiewiet", 14 | "homepage": "https://wyrihaximus.net/", 15 | "email": "reactphp@ceesjankiewiet.nl" 16 | }, 17 | { 18 | "name": "Jan Sorgalla", 19 | "homepage": "https://sorgalla.com/", 20 | "email": "jsorgalla@gmail.com" 21 | }, 22 | { 23 | "name": "Chris Boden", 24 | "homepage": "https://cboden.dev/", 25 | "email": "cboden@gmail.com" 26 | } 27 | ], 28 | "require": { 29 | "php": ">=7.1", 30 | "react/cache": "^1.0 || ^0.6 || ^0.5", 31 | "react/event-loop": "^1.2", 32 | "react/promise": "^3.2 || ^2.7 || ^1.2.1" 33 | }, 34 | "require-dev": { 35 | "phpunit/phpunit": "^9.6 || ^7.5", 36 | "react/async": "^4.3 || ^3 || ^2", 37 | "react/promise-timer": "^1.11" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "React\\Dns\\": "src/" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "React\\Tests\\Dns\\": "tests/" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/BadServerException.php: -------------------------------------------------------------------------------- 1 | `fe80:1`) 91 | if (strpos($ip, ':') !== false && ($pos = strpos($ip, '%')) !== false) { 92 | $ip = substr($ip, 0, $pos); 93 | } 94 | 95 | if (@inet_pton($ip) !== false) { 96 | $config->nameservers[] = $ip; 97 | } 98 | } 99 | 100 | return $config; 101 | } 102 | 103 | /** 104 | * Loads the DNS configurations from Windows's WMIC (from the given command or default command) 105 | * 106 | * Note that this method blocks while loading the given command and should 107 | * thus be used with care! While this should be relatively fast for normal 108 | * WMIC commands, it remains unknown if this may block under certain 109 | * circumstances. In particular, this method should only be executed before 110 | * the loop starts, not while it is running. 111 | * 112 | * Note that this method will only try to execute the given command try to 113 | * parse its output, irrespective of whether this command exists. In 114 | * particular, this command is only available on Windows. Currently, this 115 | * will only parse valid nameserver entries from the command output and will 116 | * ignore all other output without complaining. 117 | * 118 | * Note that the previous section implies that this may return an empty 119 | * `Config` object if no valid nameserver entries can be found. 120 | * 121 | * @param ?string $command (advanced) should not be given (NULL) unless you know what you're doing 122 | * @return self 123 | * @link https://ss64.com/nt/wmic.html 124 | */ 125 | public static function loadWmicBlocking($command = null) 126 | { 127 | $contents = shell_exec($command === null ? 'wmic NICCONFIG get "DNSServerSearchOrder" /format:CSV' : $command); 128 | preg_match_all('/(?<=[{;,"])([\da-f.:]{4,})(?=[};,"])/i', $contents, $matches); 129 | 130 | $config = new self(); 131 | $config->nameservers = $matches[1]; 132 | 133 | return $config; 134 | } 135 | 136 | public $nameservers = []; 137 | } 138 | -------------------------------------------------------------------------------- /src/Config/HostsFile.php: -------------------------------------------------------------------------------- 1 | contents = $contents; 89 | } 90 | 91 | /** 92 | * Returns all IPs for the given hostname 93 | * 94 | * @param string $name 95 | * @return string[] 96 | */ 97 | public function getIpsForHost($name) 98 | { 99 | $name = strtolower($name); 100 | 101 | $ips = []; 102 | foreach (preg_split('/\r?\n/', $this->contents) as $line) { 103 | $parts = preg_split('/\s+/', $line); 104 | $ip = array_shift($parts); 105 | if ($parts && array_search($name, $parts) !== false) { 106 | // remove IPv6 zone ID (`fe80::1%lo0` => `fe80:1`) 107 | if (strpos($ip, ':') !== false && ($pos = strpos($ip, '%')) !== false) { 108 | $ip = substr($ip, 0, $pos); 109 | } 110 | 111 | if (@inet_pton($ip) !== false) { 112 | $ips[] = $ip; 113 | } 114 | } 115 | } 116 | 117 | return $ips; 118 | } 119 | 120 | /** 121 | * Returns all hostnames for the given IPv4 or IPv6 address 122 | * 123 | * @param string $ip 124 | * @return string[] 125 | */ 126 | public function getHostsForIp($ip) 127 | { 128 | // check binary representation of IP to avoid string case and short notation 129 | $ip = @inet_pton($ip); 130 | if ($ip === false) { 131 | return []; 132 | } 133 | 134 | $names = []; 135 | foreach (preg_split('/\r?\n/', $this->contents) as $line) { 136 | $parts = preg_split('/\s+/', $line, -1, PREG_SPLIT_NO_EMPTY); 137 | $addr = (string) array_shift($parts); 138 | 139 | // remove IPv6 zone ID (`fe80::1%lo0` => `fe80:1`) 140 | if (strpos($addr, ':') !== false && ($pos = strpos($addr, '%')) !== false) { 141 | $addr = substr($addr, 0, $pos); 142 | } 143 | 144 | if (@inet_pton($addr) === $ip) { 145 | foreach ($parts as $part) { 146 | $names[] = $part; 147 | } 148 | } 149 | } 150 | 151 | return $names; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Model/Message.php: -------------------------------------------------------------------------------- 1 | id = self::generateId(); 93 | $request->rd = true; 94 | $request->questions[] = $query; 95 | 96 | return $request; 97 | } 98 | 99 | /** 100 | * Creates a new response message for the given query with the given answer records 101 | * 102 | * @param Query $query 103 | * @param Record[] $answers 104 | * @return self 105 | */ 106 | public static function createResponseWithAnswersForQuery(Query $query, array $answers) 107 | { 108 | $response = new Message(); 109 | $response->id = self::generateId(); 110 | $response->qr = true; 111 | $response->rd = true; 112 | 113 | $response->questions[] = $query; 114 | 115 | foreach ($answers as $record) { 116 | $response->answers[] = $record; 117 | } 118 | 119 | return $response; 120 | } 121 | 122 | /** 123 | * generates a random 16 bit message ID 124 | * 125 | * This uses a CSPRNG so that an outside attacker that is sending spoofed 126 | * DNS response messages can not guess the message ID to avoid possible 127 | * cache poisoning attacks. 128 | * 129 | * @return int 130 | * @see self::getId() 131 | * @codeCoverageIgnore 132 | */ 133 | private static function generateId() 134 | { 135 | return random_int(0, 0xffff); 136 | } 137 | 138 | /** 139 | * The 16 bit message ID 140 | * 141 | * The response message ID has to match the request message ID. This allows 142 | * the receiver to verify this is the correct response message. An outside 143 | * attacker may try to inject fake responses by "guessing" the message ID, 144 | * so this should use a proper CSPRNG to avoid possible cache poisoning. 145 | * 146 | * @var int 16 bit message ID 147 | * @see self::generateId() 148 | */ 149 | public $id = 0; 150 | 151 | /** 152 | * @var bool Query/Response flag, query=false or response=true 153 | */ 154 | public $qr = false; 155 | 156 | /** 157 | * @var int specifies the kind of query (4 bit), see self::OPCODE_* constants 158 | * @see self::OPCODE_QUERY 159 | */ 160 | public $opcode = self::OPCODE_QUERY; 161 | 162 | /** 163 | * 164 | * @var bool Authoritative Answer 165 | */ 166 | public $aa = false; 167 | 168 | /** 169 | * @var bool TrunCation 170 | */ 171 | public $tc = false; 172 | 173 | /** 174 | * @var bool Recursion Desired 175 | */ 176 | public $rd = false; 177 | 178 | /** 179 | * @var bool Recursion Available 180 | */ 181 | public $ra = false; 182 | 183 | /** 184 | * @var int response code (4 bit), see self::RCODE_* constants 185 | * @see self::RCODE_OK 186 | */ 187 | public $rcode = Message::RCODE_OK; 188 | 189 | /** 190 | * An array of Query objects 191 | * 192 | * ```php 193 | * $questions = [ 194 | * new Query( 195 | * 'reactphp.org', 196 | * Message::TYPE_A, 197 | * Message::CLASS_IN 198 | * ) 199 | * ]; 200 | * ``` 201 | * 202 | * @var Query[] 203 | */ 204 | public $questions = []; 205 | 206 | /** 207 | * @var Record[] 208 | */ 209 | public $answers = []; 210 | 211 | /** 212 | * @var Record[] 213 | */ 214 | public $authority = []; 215 | 216 | /** 217 | * @var Record[] 218 | */ 219 | public $additional = []; 220 | } 221 | -------------------------------------------------------------------------------- /src/Model/Record.php: -------------------------------------------------------------------------------- 1 | name = $name; 148 | $this->type = $type; 149 | $this->class = $class; 150 | $this->ttl = $ttl; 151 | $this->data = $data; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Protocol/BinaryDumper.php: -------------------------------------------------------------------------------- 1 | headerToBinary($message); 20 | $data .= $this->questionToBinary($message->questions); 21 | $data .= $this->recordsToBinary($message->answers); 22 | $data .= $this->recordsToBinary($message->authority); 23 | $data .= $this->recordsToBinary($message->additional); 24 | 25 | return $data; 26 | } 27 | 28 | /** 29 | * @param Message $message 30 | * @return string 31 | */ 32 | private function headerToBinary(Message $message) 33 | { 34 | $data = ''; 35 | 36 | $data .= pack('n', $message->id); 37 | 38 | $flags = 0x00; 39 | $flags = ($flags << 1) | ($message->qr ? 1 : 0); 40 | $flags = ($flags << 4) | $message->opcode; 41 | $flags = ($flags << 1) | ($message->aa ? 1 : 0); 42 | $flags = ($flags << 1) | ($message->tc ? 1 : 0); 43 | $flags = ($flags << 1) | ($message->rd ? 1 : 0); 44 | $flags = ($flags << 1) | ($message->ra ? 1 : 0); 45 | $flags = ($flags << 3) | 0; // skip unused zero bit 46 | $flags = ($flags << 4) | $message->rcode; 47 | 48 | $data .= pack('n', $flags); 49 | 50 | $data .= pack('n', count($message->questions)); 51 | $data .= pack('n', count($message->answers)); 52 | $data .= pack('n', count($message->authority)); 53 | $data .= pack('n', count($message->additional)); 54 | 55 | return $data; 56 | } 57 | 58 | /** 59 | * @param Query[] $questions 60 | * @return string 61 | */ 62 | private function questionToBinary(array $questions) 63 | { 64 | $data = ''; 65 | 66 | foreach ($questions as $question) { 67 | $data .= $this->domainNameToBinary($question->name); 68 | $data .= pack('n*', $question->type, $question->class); 69 | } 70 | 71 | return $data; 72 | } 73 | 74 | /** 75 | * @param Record[] $records 76 | * @return string 77 | */ 78 | private function recordsToBinary(array $records) 79 | { 80 | $data = ''; 81 | 82 | foreach ($records as $record) { 83 | /* @var $record Record */ 84 | switch ($record->type) { 85 | case Message::TYPE_A: 86 | case Message::TYPE_AAAA: 87 | $binary = \inet_pton($record->data); 88 | break; 89 | case Message::TYPE_CNAME: 90 | case Message::TYPE_NS: 91 | case Message::TYPE_PTR: 92 | $binary = $this->domainNameToBinary($record->data); 93 | break; 94 | case Message::TYPE_TXT: 95 | case Message::TYPE_SPF: 96 | $binary = $this->textsToBinary($record->data); 97 | break; 98 | case Message::TYPE_MX: 99 | $binary = \pack( 100 | 'n', 101 | $record->data['priority'] 102 | ); 103 | $binary .= $this->domainNameToBinary($record->data['target']); 104 | break; 105 | case Message::TYPE_SRV: 106 | $binary = \pack( 107 | 'n*', 108 | $record->data['priority'], 109 | $record->data['weight'], 110 | $record->data['port'] 111 | ); 112 | $binary .= $this->domainNameToBinary($record->data['target']); 113 | break; 114 | case Message::TYPE_SOA: 115 | $binary = $this->domainNameToBinary($record->data['mname']); 116 | $binary .= $this->domainNameToBinary($record->data['rname']); 117 | $binary .= \pack( 118 | 'N*', 119 | $record->data['serial'], 120 | $record->data['refresh'], 121 | $record->data['retry'], 122 | $record->data['expire'], 123 | $record->data['minimum'] 124 | ); 125 | break; 126 | case Message::TYPE_CAA: 127 | $binary = \pack( 128 | 'C*', 129 | $record->data['flag'], 130 | \strlen($record->data['tag']) 131 | ); 132 | $binary .= $record->data['tag']; 133 | $binary .= $record->data['value']; 134 | break; 135 | case Message::TYPE_SSHFP: 136 | $binary = \pack( 137 | 'CCH*', 138 | $record->data['algorithm'], 139 | $record->data['type'], 140 | $record->data['fingerprint'] 141 | ); 142 | break; 143 | case Message::TYPE_OPT: 144 | $binary = ''; 145 | foreach ($record->data as $opt => $value) { 146 | if ($opt === Message::OPT_TCP_KEEPALIVE && $value !== null) { 147 | $value = \pack('n', round($value * 10)); 148 | } 149 | $binary .= \pack('n*', $opt, \strlen((string) $value)) . $value; 150 | } 151 | break; 152 | default: 153 | // RDATA is already stored as binary value for unknown record types 154 | $binary = $record->data; 155 | } 156 | 157 | $data .= $this->domainNameToBinary($record->name); 158 | $data .= \pack('nnNn', $record->type, $record->class, $record->ttl, \strlen($binary)); 159 | $data .= $binary; 160 | } 161 | 162 | return $data; 163 | } 164 | 165 | /** 166 | * @param string[] $texts 167 | * @return string 168 | */ 169 | private function textsToBinary(array $texts) 170 | { 171 | $data = ''; 172 | foreach ($texts as $text) { 173 | $data .= \chr(\strlen($text)) . $text; 174 | } 175 | return $data; 176 | } 177 | 178 | /** 179 | * @param string $host 180 | * @return string 181 | */ 182 | private function domainNameToBinary($host) 183 | { 184 | if ($host === '') { 185 | return "\0"; 186 | } 187 | 188 | // break up domain name at each dot that is not preceeded by a backslash (escaped notation) 189 | return $this->textsToBinary( 190 | \array_map( 191 | 'stripcslashes', 192 | \preg_split( 193 | '/(?parse($data, 0); 27 | if ($message === null) { 28 | throw new InvalidArgumentException('Unable to parse binary message'); 29 | } 30 | 31 | return $message; 32 | } 33 | 34 | /** 35 | * @param string $data 36 | * @param int $consumed 37 | * @return ?Message 38 | */ 39 | private function parse($data, $consumed) 40 | { 41 | if (!isset($data[12 - 1])) { 42 | return null; 43 | } 44 | 45 | list($id, $fields, $qdCount, $anCount, $nsCount, $arCount) = array_values(unpack('n*', substr($data, 0, 12))); 46 | 47 | $message = new Message(); 48 | $message->id = $id; 49 | $message->rcode = $fields & 0xf; 50 | $message->ra = (($fields >> 7) & 1) === 1; 51 | $message->rd = (($fields >> 8) & 1) === 1; 52 | $message->tc = (($fields >> 9) & 1) === 1; 53 | $message->aa = (($fields >> 10) & 1) === 1; 54 | $message->opcode = ($fields >> 11) & 0xf; 55 | $message->qr = (($fields >> 15) & 1) === 1; 56 | $consumed += 12; 57 | 58 | // parse all questions 59 | for ($i = $qdCount; $i > 0; --$i) { 60 | list($question, $consumed) = $this->parseQuestion($data, $consumed); 61 | if ($question === null) { 62 | return null; 63 | } else { 64 | $message->questions[] = $question; 65 | } 66 | } 67 | 68 | // parse all answer records 69 | for ($i = $anCount; $i > 0; --$i) { 70 | list($record, $consumed) = $this->parseRecord($data, $consumed); 71 | if ($record === null) { 72 | return null; 73 | } else { 74 | $message->answers[] = $record; 75 | } 76 | } 77 | 78 | // parse all authority records 79 | for ($i = $nsCount; $i > 0; --$i) { 80 | list($record, $consumed) = $this->parseRecord($data, $consumed); 81 | if ($record === null) { 82 | return null; 83 | } else { 84 | $message->authority[] = $record; 85 | } 86 | } 87 | 88 | // parse all additional records 89 | for ($i = $arCount; $i > 0; --$i) { 90 | list($record, $consumed) = $this->parseRecord($data, $consumed); 91 | if ($record === null) { 92 | return null; 93 | } else { 94 | $message->additional[] = $record; 95 | } 96 | } 97 | 98 | return $message; 99 | } 100 | 101 | /** 102 | * @param string $data 103 | * @param int $consumed 104 | * @return array 105 | */ 106 | private function parseQuestion($data, $consumed) 107 | { 108 | list($labels, $consumed) = $this->readLabels($data, $consumed); 109 | 110 | if ($labels === null || !isset($data[$consumed + 4 - 1])) { 111 | return [null, null]; 112 | } 113 | 114 | list($type, $class) = array_values(unpack('n*', substr($data, $consumed, 4))); 115 | $consumed += 4; 116 | 117 | return [ 118 | new Query( 119 | implode('.', $labels), 120 | $type, 121 | $class 122 | ), 123 | $consumed 124 | ]; 125 | } 126 | 127 | /** 128 | * @param string $data 129 | * @param int $consumed 130 | * @return array An array with a parsed Record on success or array with null if data is invalid/incomplete 131 | */ 132 | private function parseRecord($data, $consumed) 133 | { 134 | list($name, $consumed) = $this->readDomain($data, $consumed); 135 | 136 | if ($name === null || !isset($data[$consumed + 10 - 1])) { 137 | return [null, null]; 138 | } 139 | 140 | list($type, $class) = array_values(unpack('n*', substr($data, $consumed, 4))); 141 | $consumed += 4; 142 | 143 | list($ttl) = array_values(unpack('N', substr($data, $consumed, 4))); 144 | $consumed += 4; 145 | 146 | // TTL is a UINT32 that must not have most significant bit set for BC reasons 147 | if ($ttl < 0 || $ttl >= 1 << 31) { 148 | $ttl = 0; 149 | } 150 | 151 | list($rdLength) = array_values(unpack('n', substr($data, $consumed, 2))); 152 | $consumed += 2; 153 | 154 | if (!isset($data[$consumed + $rdLength - 1])) { 155 | return [null, null]; 156 | } 157 | 158 | $rdata = null; 159 | $expected = $consumed + $rdLength; 160 | 161 | if (Message::TYPE_A === $type) { 162 | if ($rdLength === 4) { 163 | $rdata = inet_ntop(substr($data, $consumed, $rdLength)); 164 | $consumed += $rdLength; 165 | } 166 | } elseif (Message::TYPE_AAAA === $type) { 167 | if ($rdLength === 16) { 168 | $rdata = inet_ntop(substr($data, $consumed, $rdLength)); 169 | $consumed += $rdLength; 170 | } 171 | } elseif (Message::TYPE_CNAME === $type || Message::TYPE_PTR === $type || Message::TYPE_NS === $type) { 172 | list($rdata, $consumed) = $this->readDomain($data, $consumed); 173 | } elseif (Message::TYPE_TXT === $type || Message::TYPE_SPF === $type) { 174 | $rdata = []; 175 | while ($consumed < $expected) { 176 | $len = ord($data[$consumed]); 177 | $rdata[] = (string)substr($data, $consumed + 1, $len); 178 | $consumed += $len + 1; 179 | } 180 | } elseif (Message::TYPE_MX === $type) { 181 | if ($rdLength > 2) { 182 | list($priority) = array_values(unpack('n', substr($data, $consumed, 2))); 183 | list($target, $consumed) = $this->readDomain($data, $consumed + 2); 184 | 185 | $rdata = [ 186 | 'priority' => $priority, 187 | 'target' => $target 188 | ]; 189 | } 190 | } elseif (Message::TYPE_SRV === $type) { 191 | if ($rdLength > 6) { 192 | list($priority, $weight, $port) = array_values(unpack('n*', substr($data, $consumed, 6))); 193 | list($target, $consumed) = $this->readDomain($data, $consumed + 6); 194 | 195 | $rdata = [ 196 | 'priority' => $priority, 197 | 'weight' => $weight, 198 | 'port' => $port, 199 | 'target' => $target 200 | ]; 201 | } 202 | } elseif (Message::TYPE_SSHFP === $type) { 203 | if ($rdLength > 2) { 204 | list($algorithm, $hash) = \array_values(\unpack('C*', \substr($data, $consumed, 2))); 205 | $fingerprint = \bin2hex(\substr($data, $consumed + 2, $rdLength - 2)); 206 | $consumed += $rdLength; 207 | 208 | $rdata = [ 209 | 'algorithm' => $algorithm, 210 | 'type' => $hash, 211 | 'fingerprint' => $fingerprint 212 | ]; 213 | } 214 | } elseif (Message::TYPE_SOA === $type) { 215 | list($mname, $consumed) = $this->readDomain($data, $consumed); 216 | list($rname, $consumed) = $this->readDomain($data, $consumed); 217 | 218 | if ($mname !== null && $rname !== null && isset($data[$consumed + 20 - 1])) { 219 | list($serial, $refresh, $retry, $expire, $minimum) = array_values(unpack('N*', substr($data, $consumed, 20))); 220 | $consumed += 20; 221 | 222 | $rdata = [ 223 | 'mname' => $mname, 224 | 'rname' => $rname, 225 | 'serial' => $serial, 226 | 'refresh' => $refresh, 227 | 'retry' => $retry, 228 | 'expire' => $expire, 229 | 'minimum' => $minimum 230 | ]; 231 | } 232 | } elseif (Message::TYPE_OPT === $type) { 233 | $rdata = []; 234 | while (isset($data[$consumed + 4 - 1])) { 235 | list($code, $length) = array_values(unpack('n*', substr($data, $consumed, 4))); 236 | $value = (string) substr($data, $consumed + 4, $length); 237 | if ($code === Message::OPT_TCP_KEEPALIVE && $value === '') { 238 | $value = null; 239 | } elseif ($code === Message::OPT_TCP_KEEPALIVE && $length === 2) { 240 | list($value) = array_values(unpack('n', $value)); 241 | $value = round($value * 0.1, 1); 242 | } elseif ($code === Message::OPT_TCP_KEEPALIVE) { 243 | break; 244 | } 245 | $rdata[$code] = $value; 246 | $consumed += 4 + $length; 247 | } 248 | } elseif (Message::TYPE_CAA === $type) { 249 | if ($rdLength > 3) { 250 | list($flag, $tagLength) = array_values(unpack('C*', substr($data, $consumed, 2))); 251 | 252 | if ($tagLength > 0 && $rdLength - 2 - $tagLength > 0) { 253 | $tag = substr($data, $consumed + 2, $tagLength); 254 | $value = substr($data, $consumed + 2 + $tagLength, $rdLength - 2 - $tagLength); 255 | $consumed += $rdLength; 256 | 257 | $rdata = [ 258 | 'flag' => $flag, 259 | 'tag' => $tag, 260 | 'value' => $value 261 | ]; 262 | } 263 | } 264 | } else { 265 | // unknown types simply parse rdata as an opaque binary string 266 | $rdata = substr($data, $consumed, $rdLength); 267 | $consumed += $rdLength; 268 | } 269 | 270 | // ensure parsing record data consumes expact number of bytes indicated in record length 271 | if ($consumed !== $expected || $rdata === null) { 272 | return [null, null]; 273 | } 274 | 275 | return [ 276 | new Record($name, $type, $class, $ttl, $rdata), 277 | $consumed 278 | ]; 279 | } 280 | 281 | private function readDomain($data, $consumed) 282 | { 283 | list ($labels, $consumed) = $this->readLabels($data, $consumed); 284 | 285 | if ($labels === null) { 286 | return [null, null]; 287 | } 288 | 289 | // use escaped notation for each label part, then join using dots 290 | return [ 291 | \implode( 292 | '.', 293 | \array_map( 294 | function ($label) { 295 | return \addcslashes($label, "\0..\40.\177"); 296 | }, 297 | $labels 298 | ) 299 | ), 300 | $consumed 301 | ]; 302 | } 303 | 304 | /** 305 | * @param string $data 306 | * @param int $consumed 307 | * @param int $compressionDepth maximum depth for compressed labels to avoid unreasonable recursion 308 | * @return array 309 | */ 310 | private function readLabels($data, $consumed, $compressionDepth = 127) 311 | { 312 | $labels = []; 313 | 314 | while (true) { 315 | if (!isset($data[$consumed])) { 316 | return [null, null]; 317 | } 318 | 319 | $length = \ord($data[$consumed]); 320 | 321 | // end of labels reached 322 | if ($length === 0) { 323 | $consumed += 1; 324 | break; 325 | } 326 | 327 | // first two bits set? this is a compressed label (14 bit pointer offset) 328 | if (($length & 0xc0) === 0xc0 && isset($data[$consumed + 1]) && $compressionDepth) { 329 | $offset = ($length & ~0xc0) << 8 | \ord($data[$consumed + 1]); 330 | if ($offset >= $consumed) { 331 | return [null, null]; 332 | } 333 | 334 | $consumed += 2; 335 | list($newLabels) = $this->readLabels($data, $offset, $compressionDepth - 1); 336 | 337 | if ($newLabels === null) { 338 | return [null, null]; 339 | } 340 | 341 | $labels = array_merge($labels, $newLabels); 342 | break; 343 | } 344 | 345 | // length MUST be 0-63 (6 bits only) and data has to be large enough 346 | if ($length & 0xc0 || !isset($data[$consumed + $length - 1])) { 347 | return [null, null]; 348 | } 349 | 350 | $labels[] = substr($data, $consumed + 1, $length); 351 | $consumed += $length + 1; 352 | } 353 | 354 | return [$labels, $consumed]; 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /src/Query/CachingExecutor.php: -------------------------------------------------------------------------------- 1 | executor = $executor; 24 | $this->cache = $cache; 25 | } 26 | 27 | public function query(Query $query) 28 | { 29 | $id = $query->name . ':' . $query->type . ':' . $query->class; 30 | 31 | $pending = $this->cache->get($id); 32 | return new Promise(function ($resolve, $reject) use ($query, $id, &$pending) { 33 | $pending->then( 34 | function ($message) use ($query, $id, &$pending) { 35 | // return cached response message on cache hit 36 | if ($message !== null) { 37 | return $message; 38 | } 39 | 40 | // perform DNS lookup if not already cached 41 | return $pending = $this->executor->query($query)->then( 42 | function (Message $message) use ($id) { 43 | // DNS response message received => store in cache when not truncated and return 44 | if (!$message->tc) { 45 | $this->cache->set($id, $message, $this->ttl($message)); 46 | } 47 | 48 | return $message; 49 | } 50 | ); 51 | } 52 | )->then($resolve, function ($e) use ($reject, &$pending) { 53 | $reject($e); 54 | $pending = null; 55 | }); 56 | }, function ($_, $reject) use (&$pending, $query) { 57 | $reject(new \RuntimeException('DNS query for ' . $query->describe() . ' has been cancelled')); 58 | $pending->cancel(); 59 | $pending = null; 60 | }); 61 | } 62 | 63 | /** 64 | * @param Message $message 65 | * @return int 66 | * @internal 67 | */ 68 | public function ttl(Message $message) 69 | { 70 | // select TTL from answers (should all be the same), use smallest value if available 71 | // @link https://tools.ietf.org/html/rfc2181#section-5.2 72 | $ttl = null; 73 | foreach ($message->answers as $answer) { 74 | if ($ttl === null || $answer->ttl < $ttl) { 75 | $ttl = $answer->ttl; 76 | } 77 | } 78 | 79 | if ($ttl === null) { 80 | $ttl = self::TTL; 81 | } 82 | 83 | return $ttl; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Query/CancellationException.php: -------------------------------------------------------------------------------- 1 | executor = $base; 46 | } 47 | 48 | public function query(Query $query) 49 | { 50 | $key = $this->serializeQueryToIdentity($query); 51 | if (isset($this->pending[$key])) { 52 | // same query is already pending, so use shared reference to pending query 53 | $promise = $this->pending[$key]; 54 | ++$this->counts[$key]; 55 | } else { 56 | // no such query pending, so start new query and keep reference until it's fulfilled or rejected 57 | $promise = $this->executor->query($query); 58 | $this->pending[$key] = $promise; 59 | $this->counts[$key] = 1; 60 | 61 | $promise->then(function () use ($key) { 62 | unset($this->pending[$key], $this->counts[$key]); 63 | }, function () use ($key) { 64 | unset($this->pending[$key], $this->counts[$key]); 65 | }); 66 | } 67 | 68 | // Return a child promise awaiting the pending query. 69 | // Cancelling this child promise should only cancel the pending query 70 | // when no other child promise is awaiting the same query. 71 | return new Promise(function ($resolve, $reject) use ($promise) { 72 | $promise->then($resolve, $reject); 73 | }, function () use (&$promise, $key, $query) { 74 | if (--$this->counts[$key] < 1) { 75 | unset($this->pending[$key], $this->counts[$key]); 76 | $promise->cancel(); 77 | $promise = null; 78 | } 79 | throw new \RuntimeException('DNS query for ' . $query->describe() . ' has been cancelled'); 80 | }); 81 | } 82 | 83 | private function serializeQueryToIdentity(Query $query) 84 | { 85 | return sprintf('%s:%s:%s', $query->name, $query->type, $query->class); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Query/ExecutorInterface.php: -------------------------------------------------------------------------------- 1 | query($query)->then( 17 | * function (React\Dns\Model\Message $response) { 18 | * // response message successfully received 19 | * var_dump($response->rcode, $response->answers); 20 | * }, 21 | * function (Exception $error) { 22 | * // failed to query due to $error 23 | * } 24 | * ); 25 | * ``` 26 | * 27 | * The returned Promise MUST be implemented in such a way that it can be 28 | * cancelled when it is still pending. Cancelling a pending promise MUST 29 | * reject its value with an Exception. It SHOULD clean up any underlying 30 | * resources and references as applicable. 31 | * 32 | * ```php 33 | * $promise = $executor->query($query); 34 | * 35 | * $promise->cancel(); 36 | * ``` 37 | * 38 | * @param Query $query 39 | * @return \React\Promise\PromiseInterface<\React\Dns\Model\Message> 40 | * resolves with response message on success or rejects with an Exception on error 41 | */ 42 | public function query(Query $query); 43 | } 44 | -------------------------------------------------------------------------------- /src/Query/FallbackExecutor.php: -------------------------------------------------------------------------------- 1 | executor = $executor; 15 | $this->fallback = $fallback; 16 | } 17 | 18 | public function query(Query $query) 19 | { 20 | $cancelled = false; 21 | $promise = $this->executor->query($query); 22 | 23 | return new Promise(function ($resolve, $reject) use (&$promise, $query, &$cancelled) { 24 | $promise->then($resolve, function (\Exception $e1) use ($query, $resolve, $reject, &$cancelled, &$promise) { 25 | // reject if primary resolution rejected due to cancellation 26 | if ($cancelled) { 27 | $reject($e1); 28 | return; 29 | } 30 | 31 | // start fallback query if primary query rejected 32 | $promise = $this->fallback->query($query)->then($resolve, function (\Exception $e2) use ($e1, $reject) { 33 | $append = $e2->getMessage(); 34 | if (($pos = strpos($append, ':')) !== false) { 35 | $append = substr($append, $pos + 2); 36 | } 37 | 38 | // reject with combined error message if both queries fail 39 | $reject(new \RuntimeException($e1->getMessage() . '. ' . $append)); 40 | }); 41 | }); 42 | }, function () use (&$promise, &$cancelled) { 43 | // cancel pending query (primary or fallback) 44 | $cancelled = true; 45 | $promise->cancel(); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Query/HostsFileExecutor.php: -------------------------------------------------------------------------------- 1 | hosts = $hosts; 25 | $this->fallback = $fallback; 26 | } 27 | 28 | public function query(Query $query) 29 | { 30 | if ($query->class === Message::CLASS_IN && ($query->type === Message::TYPE_A || $query->type === Message::TYPE_AAAA)) { 31 | // forward lookup for type A or AAAA 32 | $records = []; 33 | $expectsColon = $query->type === Message::TYPE_AAAA; 34 | foreach ($this->hosts->getIpsForHost($query->name) as $ip) { 35 | // ensure this is an IPv4/IPV6 address according to query type 36 | if ((strpos($ip, ':') !== false) === $expectsColon) { 37 | $records[] = new Record($query->name, $query->type, $query->class, 0, $ip); 38 | } 39 | } 40 | 41 | if ($records) { 42 | return resolve( 43 | Message::createResponseWithAnswersForQuery($query, $records) 44 | ); 45 | } 46 | } elseif ($query->class === Message::CLASS_IN && $query->type === Message::TYPE_PTR) { 47 | // reverse lookup: extract IPv4 or IPv6 from special `.arpa` domain 48 | $ip = $this->getIpFromHost($query->name); 49 | 50 | if ($ip !== null) { 51 | $records = []; 52 | foreach ($this->hosts->getHostsForIp($ip) as $host) { 53 | $records[] = new Record($query->name, $query->type, $query->class, 0, $host); 54 | } 55 | 56 | if ($records) { 57 | return resolve( 58 | Message::createResponseWithAnswersForQuery($query, $records) 59 | ); 60 | } 61 | } 62 | } 63 | 64 | return $this->fallback->query($query); 65 | } 66 | 67 | private function getIpFromHost($host) 68 | { 69 | if (substr($host, -13) === '.in-addr.arpa') { 70 | // IPv4: read as IP and reverse bytes 71 | $ip = @inet_pton(substr($host, 0, -13)); 72 | if ($ip === false || isset($ip[4])) { 73 | return null; 74 | } 75 | 76 | return inet_ntop(strrev($ip)); 77 | } elseif (substr($host, -9) === '.ip6.arpa') { 78 | // IPv6: replace dots, reverse nibbles and interpret as hexadecimal string 79 | $ip = @inet_ntop(pack('H*', strrev(str_replace('.', '', substr($host, 0, -9))))); 80 | if ($ip === false) { 81 | return null; 82 | } 83 | 84 | return $ip; 85 | } else { 86 | return null; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Query/Query.php: -------------------------------------------------------------------------------- 1 | name = $name; 41 | $this->type = $type; 42 | $this->class = $class; 43 | } 44 | 45 | /** 46 | * Describes the hostname and query type/class for this query 47 | * 48 | * The output format is supposed to be human readable and is subject to change. 49 | * The format is inspired by RFC 3597 when handling unkown types/classes. 50 | * 51 | * @return string "example.com (A)" or "example.com (CLASS0 TYPE1234)" 52 | * @link https://tools.ietf.org/html/rfc3597 53 | */ 54 | public function describe() 55 | { 56 | $class = $this->class !== Message::CLASS_IN ? 'CLASS' . $this->class . ' ' : ''; 57 | 58 | $type = 'TYPE' . $this->type; 59 | $ref = new \ReflectionClass(Message::class); 60 | foreach ($ref->getConstants() as $name => $value) { 61 | if ($value === $this->type && \strpos($name, 'TYPE_') === 0) { 62 | $type = \substr($name, 5); 63 | break; 64 | } 65 | } 66 | 67 | return $this->name . ' (' . $class . $type . ')'; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Query/RetryExecutor.php: -------------------------------------------------------------------------------- 1 | executor = $executor; 16 | $this->retries = $retries; 17 | } 18 | 19 | public function query(Query $query) 20 | { 21 | return $this->tryQuery($query, $this->retries); 22 | } 23 | 24 | public function tryQuery(Query $query, $retries) 25 | { 26 | $deferred = new Deferred(function () use (&$promise) { 27 | if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { 28 | $promise->cancel(); 29 | } 30 | }); 31 | 32 | $success = function ($value) use ($deferred, &$errorback) { 33 | $errorback = null; 34 | $deferred->resolve($value); 35 | }; 36 | 37 | $errorback = function ($e) use ($deferred, &$promise, $query, $success, &$errorback, &$retries) { 38 | if (!$e instanceof TimeoutException) { 39 | $errorback = null; 40 | $deferred->reject($e); 41 | } elseif ($retries <= 0) { 42 | $errorback = null; 43 | $deferred->reject($e = new \RuntimeException( 44 | 'DNS query for ' . $query->describe() . ' failed: too many retries', 45 | 0, 46 | $e 47 | )); 48 | 49 | // avoid garbage references by replacing all closures in call stack. 50 | // what a lovely piece of code! 51 | $r = new \ReflectionProperty(\Exception::class, 'trace'); 52 | $r->setAccessible(true); 53 | $trace = $r->getValue($e); 54 | 55 | // Exception trace arguments are not available on some PHP 7.4 installs 56 | // @codeCoverageIgnoreStart 57 | foreach ($trace as $ti => $one) { 58 | if (isset($one['args'])) { 59 | foreach ($one['args'] as $ai => $arg) { 60 | if ($arg instanceof \Closure) { 61 | $trace[$ti]['args'][$ai] = 'Object(' . \get_class($arg) . ')'; 62 | } 63 | } 64 | } 65 | } 66 | // @codeCoverageIgnoreEnd 67 | $r->setValue($e, $trace); 68 | } else { 69 | --$retries; 70 | $promise = $this->executor->query($query)->then( 71 | $success, 72 | $errorback 73 | ); 74 | } 75 | }; 76 | 77 | $promise = $this->executor->query($query)->then( 78 | $success, 79 | $errorback 80 | ); 81 | 82 | return $deferred->promise(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Query/SelectiveTransportExecutor.php: -------------------------------------------------------------------------------- 1 | query( 22 | * new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) 23 | * )->then(function (Message $message) { 24 | * foreach ($message->answers as $answer) { 25 | * echo 'IPv6: ' . $answer->data . PHP_EOL; 26 | * } 27 | * }, 'printf'); 28 | * ``` 29 | * 30 | * Note that this executor only implements the logic to select the correct 31 | * transport for the given DNS query. Implementing the correct transport logic, 32 | * implementing timeouts and any retry logic is left up to the given executors, 33 | * see also [`UdpTransportExecutor`](#udptransportexecutor) and 34 | * [`TcpTransportExecutor`](#tcptransportexecutor) for more details. 35 | * 36 | * Note that this executor is entirely async and as such allows you to execute 37 | * any number of queries concurrently. You should probably limit the number of 38 | * concurrent queries in your application or you're very likely going to face 39 | * rate limitations and bans on the resolver end. For many common applications, 40 | * you may want to avoid sending the same query multiple times when the first 41 | * one is still pending, so you will likely want to use this in combination with 42 | * a `CoopExecutor` like this: 43 | * 44 | * ```php 45 | * $executor = new CoopExecutor( 46 | * new SelectiveTransportExecutor( 47 | * $datagramExecutor, 48 | * $streamExecutor 49 | * ) 50 | * ); 51 | * ``` 52 | */ 53 | class SelectiveTransportExecutor implements ExecutorInterface 54 | { 55 | private $datagramExecutor; 56 | private $streamExecutor; 57 | 58 | public function __construct(ExecutorInterface $datagramExecutor, ExecutorInterface $streamExecutor) 59 | { 60 | $this->datagramExecutor = $datagramExecutor; 61 | $this->streamExecutor = $streamExecutor; 62 | } 63 | 64 | public function query(Query $query) 65 | { 66 | $pending = $this->datagramExecutor->query($query); 67 | 68 | return new Promise(function ($resolve, $reject) use (&$pending, $query) { 69 | $pending->then( 70 | $resolve, 71 | function ($e) use (&$pending, $query, $resolve, $reject) { 72 | if ($e->getCode() === (\defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90)) { 73 | $pending = $this->streamExecutor->query($query)->then($resolve, $reject); 74 | } else { 75 | $reject($e); 76 | } 77 | } 78 | ); 79 | }, function () use (&$pending) { 80 | $pending->cancel(); 81 | $pending = null; 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Query/TcpTransportExecutor.php: -------------------------------------------------------------------------------- 1 | query( 25 | * new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) 26 | * )->then(function (Message $message) { 27 | * foreach ($message->answers as $answer) { 28 | * echo 'IPv6: ' . $answer->data . PHP_EOL; 29 | * } 30 | * }, 'printf'); 31 | * ``` 32 | * 33 | * See also [example #92](examples). 34 | * 35 | * Note that this executor does not implement a timeout, so you will very likely 36 | * want to use this in combination with a `TimeoutExecutor` like this: 37 | * 38 | * ```php 39 | * $executor = new TimeoutExecutor( 40 | * new TcpTransportExecutor($nameserver), 41 | * 3.0 42 | * ); 43 | * ``` 44 | * 45 | * Unlike the `UdpTransportExecutor`, this class uses a reliable TCP/IP 46 | * transport, so you do not necessarily have to implement any retry logic. 47 | * 48 | * Note that this executor is entirely async and as such allows you to execute 49 | * queries concurrently. The first query will establish a TCP/IP socket 50 | * connection to the DNS server which will be kept open for a short period. 51 | * Additional queries will automatically reuse this existing socket connection 52 | * to the DNS server, will pipeline multiple requests over this single 53 | * connection and will keep an idle connection open for a short period. The 54 | * initial TCP/IP connection overhead may incur a slight delay if you only send 55 | * occasional queries – when sending a larger number of concurrent queries over 56 | * an existing connection, it becomes increasingly more efficient and avoids 57 | * creating many concurrent sockets like the UDP-based executor. You may still 58 | * want to limit the number of (concurrent) queries in your application or you 59 | * may be facing rate limitations and bans on the resolver end. For many common 60 | * applications, you may want to avoid sending the same query multiple times 61 | * when the first one is still pending, so you will likely want to use this in 62 | * combination with a `CoopExecutor` like this: 63 | * 64 | * ```php 65 | * $executor = new CoopExecutor( 66 | * new TimeoutExecutor( 67 | * new TcpTransportExecutor($nameserver), 68 | * 3.0 69 | * ) 70 | * ); 71 | * ``` 72 | * 73 | * > Internally, this class uses PHP's TCP/IP sockets and does not take advantage 74 | * of [react/socket](https://github.com/reactphp/socket) purely for 75 | * organizational reasons to avoid a cyclic dependency between the two 76 | * packages. Higher-level components should take advantage of the Socket 77 | * component instead of reimplementing this socket logic from scratch. 78 | */ 79 | class TcpTransportExecutor implements ExecutorInterface 80 | { 81 | private $nameserver; 82 | private $loop; 83 | private $parser; 84 | private $dumper; 85 | 86 | /** 87 | * @var ?resource 88 | */ 89 | private $socket; 90 | 91 | /** 92 | * @var Deferred[] 93 | */ 94 | private $pending = []; 95 | 96 | /** 97 | * @var string[] 98 | */ 99 | private $names = []; 100 | 101 | /** 102 | * Maximum idle time when socket is current unused (i.e. no pending queries outstanding) 103 | * 104 | * If a new query is to be sent during the idle period, we can reuse the 105 | * existing socket without having to wait for a new socket connection. 106 | * This uses a rather small, hard-coded value to not keep any unneeded 107 | * sockets open and to not keep the loop busy longer than needed. 108 | * 109 | * A future implementation may take advantage of `edns-tcp-keepalive` to keep 110 | * the socket open for longer periods. This will likely require explicit 111 | * configuration because this may consume additional resources and also keep 112 | * the loop busy for longer than expected in some applications. 113 | * 114 | * @var float 115 | * @link https://tools.ietf.org/html/rfc7766#section-6.2.1 116 | * @link https://tools.ietf.org/html/rfc7828 117 | */ 118 | private $idlePeriod = 0.001; 119 | 120 | /** 121 | * @var ?\React\EventLoop\TimerInterface 122 | */ 123 | private $idleTimer; 124 | 125 | private $writeBuffer = ''; 126 | private $writePending = false; 127 | 128 | private $readBuffer = ''; 129 | private $readPending = false; 130 | 131 | /** @var string */ 132 | private $readChunk = 0xffff; 133 | 134 | /** 135 | * @param string $nameserver 136 | * @param ?LoopInterface $loop 137 | */ 138 | public function __construct($nameserver, ?LoopInterface $loop = null) 139 | { 140 | if (\strpos($nameserver, '[') === false && \substr_count($nameserver, ':') >= 2 && \strpos($nameserver, '://') === false) { 141 | // several colons, but not enclosed in square brackets => enclose IPv6 address in square brackets 142 | $nameserver = '[' . $nameserver . ']'; 143 | } 144 | 145 | $parts = \parse_url((\strpos($nameserver, '://') === false ? 'tcp://' : '') . $nameserver); 146 | if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'tcp' || @\inet_pton(\trim($parts['host'], '[]')) === false) { 147 | throw new \InvalidArgumentException('Invalid nameserver address given'); 148 | } 149 | 150 | $this->nameserver = 'tcp://' . $parts['host'] . ':' . ($parts['port'] ?? 53); 151 | $this->loop = $loop ?: Loop::get(); 152 | $this->parser = new Parser(); 153 | $this->dumper = new BinaryDumper(); 154 | } 155 | 156 | public function query(Query $query) 157 | { 158 | $request = Message::createRequestForQuery($query); 159 | 160 | // keep shuffing message ID to avoid using the same message ID for two pending queries at the same time 161 | while (isset($this->pending[$request->id])) { 162 | $request->id = \mt_rand(0, 0xffff); // @codeCoverageIgnore 163 | } 164 | 165 | $queryData = $this->dumper->toBinary($request); 166 | $length = \strlen($queryData); 167 | if ($length > 0xffff) { 168 | return reject(new \RuntimeException( 169 | 'DNS query for ' . $query->describe() . ' failed: Query too large for TCP transport' 170 | )); 171 | } 172 | 173 | $queryData = \pack('n', $length) . $queryData; 174 | 175 | if ($this->socket === null) { 176 | // create async TCP/IP connection (may take a while) 177 | $socket = @\stream_socket_client($this->nameserver, $errno, $errstr, 0, \STREAM_CLIENT_CONNECT | \STREAM_CLIENT_ASYNC_CONNECT); 178 | if ($socket === false) { 179 | return reject(new \RuntimeException( 180 | 'DNS query for ' . $query->describe() . ' failed: Unable to connect to DNS server ' . $this->nameserver . ' (' . $errstr . ')', 181 | $errno 182 | )); 183 | } 184 | 185 | // set socket to non-blocking and wait for it to become writable (connection success/rejected) 186 | \stream_set_blocking($socket, false); 187 | if (\function_exists('stream_set_chunk_size')) { 188 | \stream_set_chunk_size($socket, $this->readChunk); // @codeCoverageIgnore 189 | } 190 | $this->socket = $socket; 191 | } 192 | 193 | if ($this->idleTimer !== null) { 194 | $this->loop->cancelTimer($this->idleTimer); 195 | $this->idleTimer = null; 196 | } 197 | 198 | // wait for socket to become writable to actually write out data 199 | $this->writeBuffer .= $queryData; 200 | if (!$this->writePending) { 201 | $this->writePending = true; 202 | $this->loop->addWriteStream($this->socket, [$this, 'handleWritable']); 203 | } 204 | 205 | $deferred = new Deferred(function () use ($request) { 206 | // remove from list of pending names, but remember pending query 207 | $name = $this->names[$request->id]; 208 | unset($this->names[$request->id]); 209 | $this->checkIdle(); 210 | 211 | throw new CancellationException('DNS query for ' . $name . ' has been cancelled'); 212 | }); 213 | 214 | $this->pending[$request->id] = $deferred; 215 | $this->names[$request->id] = $query->describe(); 216 | 217 | return $deferred->promise(); 218 | } 219 | 220 | /** 221 | * @internal 222 | */ 223 | public function handleWritable() 224 | { 225 | if ($this->readPending === false) { 226 | $name = @\stream_socket_get_name($this->socket, true); 227 | if ($name === false) { 228 | // Connection failed? Check socket error if available for underlying errno/errstr. 229 | // @codeCoverageIgnoreStart 230 | if (\function_exists('socket_import_stream')) { 231 | $socket = \socket_import_stream($this->socket); 232 | $errno = \socket_get_option($socket, \SOL_SOCKET, \SO_ERROR); 233 | $errstr = \socket_strerror($errno); 234 | } else { 235 | $errno = \defined('SOCKET_ECONNREFUSED') ? \SOCKET_ECONNREFUSED : 111; 236 | $errstr = 'Connection refused'; 237 | } 238 | // @codeCoverageIgnoreEnd 239 | 240 | $this->closeError('Unable to connect to DNS server ' . $this->nameserver . ' (' . $errstr . ')', $errno); 241 | return; 242 | } 243 | 244 | $this->readPending = true; 245 | $this->loop->addReadStream($this->socket, [$this, 'handleRead']); 246 | } 247 | 248 | $errno = 0; 249 | $errstr = ''; 250 | \set_error_handler(function ($_, $error) use (&$errno, &$errstr) { 251 | // Match errstr from PHP's warning message. 252 | // fwrite(): Send of 327712 bytes failed with errno=32 Broken pipe 253 | \preg_match('/errno=(\d+) (.+)/', $error, $m); 254 | $errno = (int) ($m[1] ?? 0); 255 | $errstr = $m[2] ?? $error; 256 | }); 257 | 258 | $written = \fwrite($this->socket, $this->writeBuffer); 259 | 260 | \restore_error_handler(); 261 | 262 | if ($written === false || $written === 0) { 263 | $this->closeError( 264 | 'Unable to send query to DNS server ' . $this->nameserver . ' (' . $errstr . ')', 265 | $errno 266 | ); 267 | return; 268 | } 269 | 270 | if (isset($this->writeBuffer[$written])) { 271 | $this->writeBuffer = \substr($this->writeBuffer, $written); 272 | } else { 273 | $this->loop->removeWriteStream($this->socket); 274 | $this->writePending = false; 275 | $this->writeBuffer = ''; 276 | } 277 | } 278 | 279 | /** 280 | * @internal 281 | */ 282 | public function handleRead() 283 | { 284 | // read one chunk of data from the DNS server 285 | // any error is fatal, this is a stream of TCP/IP data 286 | $chunk = @\fread($this->socket, $this->readChunk); 287 | if ($chunk === false || $chunk === '') { 288 | $this->closeError('Connection to DNS server ' . $this->nameserver . ' lost'); 289 | return; 290 | } 291 | 292 | // reassemble complete message by concatenating all chunks. 293 | $this->readBuffer .= $chunk; 294 | 295 | // response message header contains at least 12 bytes 296 | while (isset($this->readBuffer[11])) { 297 | // read response message length from first 2 bytes and ensure we have length + data in buffer 298 | list(, $length) = \unpack('n', $this->readBuffer); 299 | if (!isset($this->readBuffer[$length + 1])) { 300 | return; 301 | } 302 | 303 | $data = \substr($this->readBuffer, 2, $length); 304 | $this->readBuffer = (string)substr($this->readBuffer, $length + 2); 305 | 306 | try { 307 | $response = $this->parser->parseMessage($data); 308 | } catch (\Exception $e) { 309 | // reject all pending queries if we received an invalid message from remote server 310 | $this->closeError('Invalid message received from DNS server ' . $this->nameserver); 311 | return; 312 | } 313 | 314 | // reject all pending queries if we received an unexpected response ID or truncated response 315 | if (!isset($this->pending[$response->id]) || $response->tc) { 316 | $this->closeError('Invalid response message received from DNS server ' . $this->nameserver); 317 | return; 318 | } 319 | 320 | $deferred = $this->pending[$response->id]; 321 | unset($this->pending[$response->id], $this->names[$response->id]); 322 | 323 | $deferred->resolve($response); 324 | 325 | $this->checkIdle(); 326 | } 327 | } 328 | 329 | /** 330 | * @internal 331 | * @param string $reason 332 | * @param int $code 333 | */ 334 | public function closeError($reason, $code = 0) 335 | { 336 | $this->readBuffer = ''; 337 | if ($this->readPending) { 338 | $this->loop->removeReadStream($this->socket); 339 | $this->readPending = false; 340 | } 341 | 342 | $this->writeBuffer = ''; 343 | if ($this->writePending) { 344 | $this->loop->removeWriteStream($this->socket); 345 | $this->writePending = false; 346 | } 347 | 348 | if ($this->idleTimer !== null) { 349 | $this->loop->cancelTimer($this->idleTimer); 350 | $this->idleTimer = null; 351 | } 352 | 353 | @\fclose($this->socket); 354 | $this->socket = null; 355 | 356 | foreach ($this->names as $id => $name) { 357 | $this->pending[$id]->reject(new \RuntimeException( 358 | 'DNS query for ' . $name . ' failed: ' . $reason, 359 | $code 360 | )); 361 | } 362 | $this->pending = $this->names = []; 363 | } 364 | 365 | /** 366 | * @internal 367 | */ 368 | public function checkIdle() 369 | { 370 | if ($this->idleTimer === null && !$this->names) { 371 | $this->idleTimer = $this->loop->addTimer($this->idlePeriod, function () { 372 | $this->closeError('Idle timeout'); 373 | }); 374 | } 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/Query/TimeoutException.php: -------------------------------------------------------------------------------- 1 | executor = $executor; 18 | $this->loop = $loop ?: Loop::get(); 19 | $this->timeout = $timeout; 20 | } 21 | 22 | public function query(Query $query) 23 | { 24 | $promise = $this->executor->query($query); 25 | 26 | return new Promise(function ($resolve, $reject) use ($promise, $query) { 27 | $timer = null; 28 | $promise = $promise->then(function ($v) use (&$timer, $resolve) { 29 | if ($timer) { 30 | $this->loop->cancelTimer($timer); 31 | } 32 | $timer = false; 33 | $resolve($v); 34 | }, function ($v) use (&$timer, $reject) { 35 | if ($timer) { 36 | $this->loop->cancelTimer($timer); 37 | } 38 | $timer = false; 39 | $reject($v); 40 | }); 41 | 42 | // promise already resolved => no need to start timer 43 | if ($timer === false) { 44 | return; 45 | } 46 | 47 | // start timeout timer which will cancel the pending promise 48 | $timer = $this->loop->addTimer($this->timeout, function () use (&$promise, $reject, $query) { 49 | $reject(new TimeoutException( 50 | 'DNS query for ' . $query->describe() . ' timed out' 51 | )); 52 | 53 | // Cancel pending query to clean up any underlying resources and references. 54 | // Avoid garbage references in call stack by passing pending promise by reference. 55 | assert(\method_exists($promise, 'cancel')); 56 | $promise->cancel(); 57 | $promise = null; 58 | }); 59 | }, function () use (&$promise) { 60 | // Cancelling this promise will cancel the pending query, thus triggering the rejection logic above. 61 | // Avoid garbage references in call stack by passing pending promise by reference. 62 | assert(\method_exists($promise, 'cancel')); 63 | $promise->cancel(); 64 | $promise = null; 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Query/UdpTransportExecutor.php: -------------------------------------------------------------------------------- 1 | query( 26 | * new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) 27 | * )->then(function (Message $message) { 28 | * foreach ($message->answers as $answer) { 29 | * echo 'IPv6: ' . $answer->data . PHP_EOL; 30 | * } 31 | * }, 'printf'); 32 | * ``` 33 | * 34 | * See also the [fourth example](examples). 35 | * 36 | * Note that this executor does not implement a timeout, so you will very likely 37 | * want to use this in combination with a `TimeoutExecutor` like this: 38 | * 39 | * ```php 40 | * $executor = new TimeoutExecutor( 41 | * new UdpTransportExecutor($nameserver), 42 | * 3.0 43 | * ); 44 | * ``` 45 | * 46 | * Also note that this executor uses an unreliable UDP transport and that it 47 | * does not implement any retry logic, so you will likely want to use this in 48 | * combination with a `RetryExecutor` like this: 49 | * 50 | * ```php 51 | * $executor = new RetryExecutor( 52 | * new TimeoutExecutor( 53 | * new UdpTransportExecutor($nameserver), 54 | * 3.0 55 | * ) 56 | * ); 57 | * ``` 58 | * 59 | * Note that this executor is entirely async and as such allows you to execute 60 | * any number of queries concurrently. You should probably limit the number of 61 | * concurrent queries in your application or you're very likely going to face 62 | * rate limitations and bans on the resolver end. For many common applications, 63 | * you may want to avoid sending the same query multiple times when the first 64 | * one is still pending, so you will likely want to use this in combination with 65 | * a `CoopExecutor` like this: 66 | * 67 | * ```php 68 | * $executor = new CoopExecutor( 69 | * new RetryExecutor( 70 | * new TimeoutExecutor( 71 | * new UdpTransportExecutor($nameserver), 72 | * 3.0 73 | * ) 74 | * ) 75 | * ); 76 | * ``` 77 | * 78 | * > Internally, this class uses PHP's UDP sockets and does not take advantage 79 | * of [react/datagram](https://github.com/reactphp/datagram) purely for 80 | * organizational reasons to avoid a cyclic dependency between the two 81 | * packages. Higher-level components should take advantage of the Datagram 82 | * component instead of reimplementing this socket logic from scratch. 83 | */ 84 | final class UdpTransportExecutor implements ExecutorInterface 85 | { 86 | private $nameserver; 87 | private $loop; 88 | private $parser; 89 | private $dumper; 90 | 91 | /** 92 | * maximum UDP packet size to send and receive 93 | * 94 | * @var int 95 | */ 96 | private $maxPacketSize = 512; 97 | 98 | /** 99 | * @param string $nameserver 100 | * @param ?LoopInterface $loop 101 | */ 102 | public function __construct($nameserver, ?LoopInterface $loop = null) 103 | { 104 | if (\strpos($nameserver, '[') === false && \substr_count($nameserver, ':') >= 2 && \strpos($nameserver, '://') === false) { 105 | // several colons, but not enclosed in square brackets => enclose IPv6 address in square brackets 106 | $nameserver = '[' . $nameserver . ']'; 107 | } 108 | 109 | $parts = \parse_url((\strpos($nameserver, '://') === false ? 'udp://' : '') . $nameserver); 110 | if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'udp' || @\inet_pton(\trim($parts['host'], '[]')) === false) { 111 | throw new \InvalidArgumentException('Invalid nameserver address given'); 112 | } 113 | 114 | $this->nameserver = 'udp://' . $parts['host'] . ':' . ($parts['port'] ?? 53); 115 | $this->loop = $loop ?: Loop::get(); 116 | $this->parser = new Parser(); 117 | $this->dumper = new BinaryDumper(); 118 | } 119 | 120 | public function query(Query $query) 121 | { 122 | $request = Message::createRequestForQuery($query); 123 | 124 | $queryData = $this->dumper->toBinary($request); 125 | if (isset($queryData[$this->maxPacketSize])) { 126 | return reject(new \RuntimeException( 127 | 'DNS query for ' . $query->describe() . ' failed: Query too large for UDP transport', 128 | \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90 129 | )); 130 | } 131 | 132 | // UDP connections are instant, so try connection without a loop or timeout 133 | $errno = 0; 134 | $errstr = ''; 135 | $socket = @\stream_socket_client($this->nameserver, $errno, $errstr, 0); 136 | if ($socket === false) { 137 | return reject(new \RuntimeException( 138 | 'DNS query for ' . $query->describe() . ' failed: Unable to connect to DNS server ' . $this->nameserver . ' (' . $errstr . ')', 139 | $errno 140 | )); 141 | } 142 | 143 | // set socket to non-blocking and immediately try to send (fill write buffer) 144 | \stream_set_blocking($socket, false); 145 | 146 | \set_error_handler(function ($_, $error) use (&$errno, &$errstr) { 147 | // Write may potentially fail, but most common errors are already caught by connection check above. 148 | // Among others, macOS is known to report here when trying to send to broadcast address. 149 | // This can also be reproduced by writing data exceeding `stream_set_chunk_size()` to a server refusing UDP data. 150 | // fwrite(): send of 8192 bytes failed with errno=111 Connection refused 151 | \preg_match('/errno=(\d+) (.+)/', $error, $m); 152 | $errno = (int) ($m[1] ?? 0); 153 | $errstr = $m[2] ?? $error; 154 | }); 155 | 156 | $written = \fwrite($socket, $queryData); 157 | 158 | \restore_error_handler(); 159 | 160 | if ($written !== \strlen($queryData)) { 161 | return reject(new \RuntimeException( 162 | 'DNS query for ' . $query->describe() . ' failed: Unable to send query to DNS server ' . $this->nameserver . ' (' . $errstr . ')', 163 | $errno 164 | )); 165 | } 166 | 167 | $deferred = new Deferred(function () use ($socket, $query) { 168 | // cancellation should remove socket from loop and close socket 169 | $this->loop->removeReadStream($socket); 170 | \fclose($socket); 171 | 172 | throw new CancellationException('DNS query for ' . $query->describe() . ' has been cancelled'); 173 | }); 174 | 175 | $this->loop->addReadStream($socket, function ($socket) use ($deferred, $query, $request) { 176 | // try to read a single data packet from the DNS server 177 | // ignoring any errors, this is uses UDP packets and not a stream of data 178 | $data = @\fread($socket, $this->maxPacketSize); 179 | if ($data === false) { 180 | return; 181 | } 182 | 183 | try { 184 | $response = $this->parser->parseMessage($data); 185 | } catch (\Exception $e) { 186 | // ignore and await next if we received an invalid message from remote server 187 | // this may as well be a fake response from an attacker (possible DOS) 188 | return; 189 | } 190 | 191 | // ignore and await next if we received an unexpected response ID 192 | // this may as well be a fake response from an attacker (possible cache poisoning) 193 | if ($response->id !== $request->id) { 194 | return; 195 | } 196 | 197 | // we only react to the first valid message, so remove socket from loop and close 198 | $this->loop->removeReadStream($socket); 199 | \fclose($socket); 200 | 201 | if ($response->tc) { 202 | $deferred->reject(new \RuntimeException( 203 | 'DNS query for ' . $query->describe() . ' failed: The DNS server ' . $this->nameserver . ' returned a truncated result for a UDP query', 204 | \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90 205 | )); 206 | return; 207 | } 208 | 209 | $deferred->resolve($response); 210 | }); 211 | 212 | return $deferred->promise(); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/RecordNotFoundException.php: -------------------------------------------------------------------------------- 1 | decorateHostsFileExecutor($this->createExecutor($config, $loop ?: Loop::get())); 42 | 43 | return new Resolver($executor); 44 | } 45 | 46 | /** 47 | * Creates a cached DNS resolver instance for the given DNS config and cache 48 | * 49 | * As of v1.7.0 it's recommended to pass a `Config` object instead of a 50 | * single nameserver address. If the given config contains more than one DNS 51 | * nameserver, all DNS nameservers will be used in order. The primary DNS 52 | * server will always be used first before falling back to the secondary or 53 | * tertiary DNS server. 54 | * 55 | * @param Config|string $config DNS Config object (recommended) or single nameserver address 56 | * @param ?LoopInterface $loop 57 | * @param ?CacheInterface $cache 58 | * @return \React\Dns\Resolver\ResolverInterface 59 | * @throws \InvalidArgumentException for invalid DNS server address 60 | * @throws \UnderflowException when given DNS Config object has an empty list of nameservers 61 | */ 62 | public function createCached($config, ?LoopInterface $loop = null, ?CacheInterface $cache = null) 63 | { 64 | // default to keeping maximum of 256 responses in cache unless explicitly given 65 | if (!($cache instanceof CacheInterface)) { 66 | $cache = new ArrayCache(256); 67 | } 68 | 69 | $executor = $this->createExecutor($config, $loop ?: Loop::get()); 70 | $executor = new CachingExecutor($executor, $cache); 71 | $executor = $this->decorateHostsFileExecutor($executor); 72 | 73 | return new Resolver($executor); 74 | } 75 | 76 | /** 77 | * Tries to load the hosts file and decorates the given executor on success 78 | * 79 | * @param ExecutorInterface $executor 80 | * @return ExecutorInterface 81 | * @codeCoverageIgnore 82 | */ 83 | private function decorateHostsFileExecutor(ExecutorInterface $executor) 84 | { 85 | try { 86 | $executor = new HostsFileExecutor( 87 | HostsFile::loadFromPathBlocking(), 88 | $executor 89 | ); 90 | } catch (\RuntimeException $e) { 91 | // ignore this file if it can not be loaded 92 | } 93 | 94 | // Windows does not store localhost in hosts file by default but handles this internally 95 | // To compensate for this, we explicitly use hard-coded defaults for localhost 96 | if (DIRECTORY_SEPARATOR === '\\') { 97 | $executor = new HostsFileExecutor( 98 | new HostsFile("127.0.0.1 localhost\n::1 localhost"), 99 | $executor 100 | ); 101 | } 102 | 103 | return $executor; 104 | } 105 | 106 | /** 107 | * @param Config|string $nameserver 108 | * @param LoopInterface $loop 109 | * @return CoopExecutor 110 | * @throws \InvalidArgumentException for invalid DNS server address 111 | * @throws \UnderflowException when given DNS Config object has an empty list of nameservers 112 | */ 113 | private function createExecutor($nameserver, LoopInterface $loop) 114 | { 115 | if ($nameserver instanceof Config) { 116 | if (!$nameserver->nameservers) { 117 | throw new \UnderflowException('Empty config with no DNS servers'); 118 | } 119 | 120 | // Hard-coded to check up to 3 DNS servers to match default limits in place in most systems (see MAXNS config). 121 | // Note to future self: Recursion isn't too hard, but how deep do we really want to go? 122 | $primary = reset($nameserver->nameservers); 123 | $secondary = next($nameserver->nameservers); 124 | $tertiary = next($nameserver->nameservers); 125 | 126 | if ($tertiary !== false) { 127 | // 3 DNS servers given => nest first with fallback for second and third 128 | return new CoopExecutor( 129 | new RetryExecutor( 130 | new FallbackExecutor( 131 | $this->createSingleExecutor($primary, $loop), 132 | new FallbackExecutor( 133 | $this->createSingleExecutor($secondary, $loop), 134 | $this->createSingleExecutor($tertiary, $loop) 135 | ) 136 | ) 137 | ) 138 | ); 139 | } elseif ($secondary !== false) { 140 | // 2 DNS servers given => fallback from first to second 141 | return new CoopExecutor( 142 | new RetryExecutor( 143 | new FallbackExecutor( 144 | $this->createSingleExecutor($primary, $loop), 145 | $this->createSingleExecutor($secondary, $loop) 146 | ) 147 | ) 148 | ); 149 | } else { 150 | // 1 DNS server given => use single executor 151 | $nameserver = $primary; 152 | } 153 | } 154 | 155 | return new CoopExecutor(new RetryExecutor($this->createSingleExecutor($nameserver, $loop))); 156 | } 157 | 158 | /** 159 | * @param string $nameserver 160 | * @param LoopInterface $loop 161 | * @return ExecutorInterface 162 | * @throws \InvalidArgumentException for invalid DNS server address 163 | */ 164 | private function createSingleExecutor($nameserver, LoopInterface $loop) 165 | { 166 | $parts = \parse_url($nameserver); 167 | 168 | if (isset($parts['scheme']) && $parts['scheme'] === 'tcp') { 169 | $executor = $this->createTcpExecutor($nameserver, $loop); 170 | } elseif (isset($parts['scheme']) && $parts['scheme'] === 'udp') { 171 | $executor = $this->createUdpExecutor($nameserver, $loop); 172 | } else { 173 | $executor = new SelectiveTransportExecutor( 174 | $this->createUdpExecutor($nameserver, $loop), 175 | $this->createTcpExecutor($nameserver, $loop) 176 | ); 177 | } 178 | 179 | return $executor; 180 | } 181 | 182 | /** 183 | * @param string $nameserver 184 | * @param LoopInterface $loop 185 | * @return TimeoutExecutor 186 | * @throws \InvalidArgumentException for invalid DNS server address 187 | */ 188 | private function createTcpExecutor($nameserver, LoopInterface $loop) 189 | { 190 | return new TimeoutExecutor( 191 | new TcpTransportExecutor($nameserver, $loop), 192 | 5.0, 193 | $loop 194 | ); 195 | } 196 | 197 | /** 198 | * @param string $nameserver 199 | * @param LoopInterface $loop 200 | * @return TimeoutExecutor 201 | * @throws \InvalidArgumentException for invalid DNS server address 202 | */ 203 | private function createUdpExecutor($nameserver, LoopInterface $loop) 204 | { 205 | return new TimeoutExecutor( 206 | new UdpTransportExecutor( 207 | $nameserver, 208 | $loop 209 | ), 210 | 5.0, 211 | $loop 212 | ); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/Resolver/Resolver.php: -------------------------------------------------------------------------------- 1 | executor = $executor; 20 | } 21 | 22 | public function resolve($domain) 23 | { 24 | return $this->resolveAll($domain, Message::TYPE_A)->then(function (array $ips) { 25 | return $ips[array_rand($ips)]; 26 | }); 27 | } 28 | 29 | public function resolveAll($domain, $type) 30 | { 31 | $query = new Query($domain, $type, Message::CLASS_IN); 32 | 33 | return $this->executor->query( 34 | $query 35 | )->then(function (Message $response) use ($query) { 36 | return $this->extractValues($query, $response); 37 | }); 38 | } 39 | 40 | /** 41 | * [Internal] extract all resource record values from response for this query 42 | * 43 | * @param Query $query 44 | * @param Message $response 45 | * @return array 46 | * @throws RecordNotFoundException when response indicates an error or contains no data 47 | * @internal 48 | */ 49 | public function extractValues(Query $query, Message $response) 50 | { 51 | // reject if response code indicates this is an error response message 52 | $code = $response->rcode; 53 | if ($code !== Message::RCODE_OK) { 54 | switch ($code) { 55 | case Message::RCODE_FORMAT_ERROR: 56 | $message = 'Format Error'; 57 | break; 58 | case Message::RCODE_SERVER_FAILURE: 59 | $message = 'Server Failure'; 60 | break; 61 | case Message::RCODE_NAME_ERROR: 62 | $message = 'Non-Existent Domain / NXDOMAIN'; 63 | break; 64 | case Message::RCODE_NOT_IMPLEMENTED: 65 | $message = 'Not Implemented'; 66 | break; 67 | case Message::RCODE_REFUSED: 68 | $message = 'Refused'; 69 | break; 70 | default: 71 | $message = 'Unknown error response code ' . $code; 72 | } 73 | throw new RecordNotFoundException( 74 | 'DNS query for ' . $query->describe() . ' returned an error response (' . $message . ')', 75 | $code 76 | ); 77 | } 78 | 79 | $answers = $response->answers; 80 | $addresses = $this->valuesByNameAndType($answers, $query->name, $query->type); 81 | 82 | // reject if we did not receive a valid answer (domain is valid, but no record for this type could be found) 83 | if (0 === count($addresses)) { 84 | throw new RecordNotFoundException( 85 | 'DNS query for ' . $query->describe() . ' did not return a valid answer (NOERROR / NODATA)' 86 | ); 87 | } 88 | 89 | return array_values($addresses); 90 | } 91 | 92 | /** 93 | * @param \React\Dns\Model\Record[] $answers 94 | * @param string $name 95 | * @param int $type 96 | * @return array 97 | */ 98 | private function valuesByNameAndType(array $answers, $name, $type) 99 | { 100 | // return all record values for this name and type (if any) 101 | $named = $this->filterByName($answers, $name); 102 | $records = $this->filterByType($named, $type); 103 | if ($records) { 104 | return $this->mapRecordData($records); 105 | } 106 | 107 | // no matching records found? check if there are any matching CNAMEs instead 108 | $cnameRecords = $this->filterByType($named, Message::TYPE_CNAME); 109 | if ($cnameRecords) { 110 | $cnames = $this->mapRecordData($cnameRecords); 111 | foreach ($cnames as $cname) { 112 | $records = array_merge( 113 | $records, 114 | $this->valuesByNameAndType($answers, $cname, $type) 115 | ); 116 | } 117 | } 118 | 119 | return $records; 120 | } 121 | 122 | private function filterByName(array $answers, $name) 123 | { 124 | return $this->filterByField($answers, 'name', $name); 125 | } 126 | 127 | private function filterByType(array $answers, $type) 128 | { 129 | return $this->filterByField($answers, 'type', $type); 130 | } 131 | 132 | private function filterByField(array $answers, $field, $value) 133 | { 134 | $value = strtolower($value); 135 | return array_filter($answers, function ($answer) use ($field, $value) { 136 | return $value === strtolower($answer->$field); 137 | }); 138 | } 139 | 140 | private function mapRecordData(array $records) 141 | { 142 | return array_map(function ($record) { 143 | return $record->data; 144 | }, $records); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Resolver/ResolverInterface.php: -------------------------------------------------------------------------------- 1 | resolve('reactphp.org')->then(function ($ip) { 12 | * echo 'IP for reactphp.org is ' . $ip . PHP_EOL; 13 | * }); 14 | * ``` 15 | * 16 | * This is one of the main methods in this package. It sends a DNS query 17 | * for the given $domain name to your DNS server and returns a single IP 18 | * address on success. 19 | * 20 | * If the DNS server sends a DNS response message that contains more than 21 | * one IP address for this query, it will randomly pick one of the IP 22 | * addresses from the response. If you want the full list of IP addresses 23 | * or want to send a different type of query, you should use the 24 | * [`resolveAll()`](#resolveall) method instead. 25 | * 26 | * If the DNS server sends a DNS response message that indicates an error 27 | * code, this method will reject with a `RecordNotFoundException`. Its 28 | * message and code can be used to check for the response code. 29 | * 30 | * If the DNS communication fails and the server does not respond with a 31 | * valid response message, this message will reject with an `Exception`. 32 | * 33 | * Pending DNS queries can be cancelled by cancelling its pending promise like so: 34 | * 35 | * ```php 36 | * $promise = $resolver->resolve('reactphp.org'); 37 | * 38 | * $promise->cancel(); 39 | * ``` 40 | * 41 | * @param string $domain 42 | * @return \React\Promise\PromiseInterface 43 | * resolves with a single IP address on success or rejects with an Exception on error. 44 | */ 45 | public function resolve($domain); 46 | 47 | /** 48 | * Resolves all record values for the given $domain name and query $type. 49 | * 50 | * ```php 51 | * $resolver->resolveAll('reactphp.org', Message::TYPE_A)->then(function ($ips) { 52 | * echo 'IPv4 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; 53 | * }); 54 | * 55 | * $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then(function ($ips) { 56 | * echo 'IPv6 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; 57 | * }); 58 | * ``` 59 | * 60 | * This is one of the main methods in this package. It sends a DNS query 61 | * for the given $domain name to your DNS server and returns a list with all 62 | * record values on success. 63 | * 64 | * If the DNS server sends a DNS response message that contains one or more 65 | * records for this query, it will return a list with all record values 66 | * from the response. You can use the `Message::TYPE_*` constants to control 67 | * which type of query will be sent. Note that this method always returns a 68 | * list of record values, but each record value type depends on the query 69 | * type. For example, it returns the IPv4 addresses for type `A` queries, 70 | * the IPv6 addresses for type `AAAA` queries, the hostname for type `NS`, 71 | * `CNAME` and `PTR` queries and structured data for other queries. See also 72 | * the `Record` documentation for more details. 73 | * 74 | * If the DNS server sends a DNS response message that indicates an error 75 | * code, this method will reject with a `RecordNotFoundException`. Its 76 | * message and code can be used to check for the response code. 77 | * 78 | * If the DNS communication fails and the server does not respond with a 79 | * valid response message, this message will reject with an `Exception`. 80 | * 81 | * Pending DNS queries can be cancelled by cancelling its pending promise like so: 82 | * 83 | * ```php 84 | * $promise = $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA); 85 | * 86 | * $promise->cancel(); 87 | * ``` 88 | * 89 | * @param string $domain 90 | * @return \React\Promise\PromiseInterface 91 | * Resolves with all record values on success or rejects with an Exception on error. 92 | */ 93 | public function resolveAll($domain, $type); 94 | } 95 | --------------------------------------------------------------------------------