├── _config.yml ├── .gitignore ├── phpstan.neon ├── src ├── Entities │ ├── EntityAbstract.php │ ├── Interfaces │ │ ├── Arrayable.php │ │ ├── Serializable.php │ │ └── DNSRecordInterface.php │ ├── TXTData.php │ ├── NSData.php │ ├── CNAMEData.php │ ├── PTRData.php │ ├── MXData.php │ ├── SRVData.php │ ├── IPAddress.php │ ├── CAAData.php │ ├── Hostname.php │ ├── SOAData.php │ ├── DataAbstract.php │ ├── DNSRecord.php │ ├── DNSRecordCollection.php │ └── DNSRecordType.php ├── Exceptions │ ├── InvalidArgumentException.php │ └── Exception.php ├── Resolvers │ ├── Exceptions │ │ ├── ReverseLookupFailure.php │ │ └── QueryFailure.php │ ├── Interfaces │ │ ├── Resolver.php │ │ ├── ObservableResolver.php │ │ ├── ReverseDNSQuery.php │ │ ├── Chain.php │ │ └── DNSQuery.php │ ├── Traits │ │ └── Time.php │ ├── LocalSystem.php │ ├── Dig.php │ ├── GoogleDNS.php │ ├── Cached.php │ ├── CloudFlare.php │ ├── Chain.php │ └── ResolverAbstract.php ├── Observability │ ├── Performance │ │ ├── ProfileFactory.php │ │ ├── Interfaces │ │ │ ├── Time.php │ │ │ └── ProfileInterface.php │ │ ├── Timer.php │ │ └── Profile.php │ ├── Interfaces │ │ └── Observable.php │ ├── Traits │ │ ├── Logger.php │ │ ├── Profileable.php │ │ └── Dispatcher.php │ ├── Events │ │ ├── ObservableEventAbstract.php │ │ ├── DNSQueryProfiled.php │ │ ├── DNSQueryFailed.php │ │ └── DNSQueried.php │ └── Subscribers │ │ └── STDIOSubscriber.php ├── Mappers │ ├── MapperInterface.php │ ├── MapperAbstract.php │ ├── CloudFlare.php │ ├── GoogleDNS.php │ ├── Dig.php │ └── LocalSystem.php └── Services │ ├── Interfaces │ └── LocalSystemDNS.php │ └── LocalSystemDNS.php ├── tests ├── bootstrap.php ├── Unit │ ├── Exceptions │ │ └── ExceptionTest.php │ ├── Observability │ │ ├── Performance │ │ │ ├── TimerTest.php │ │ │ └── ProfileTest.php │ │ ├── Events │ │ │ ├── ObservableEventAbstractTest.php │ │ │ ├── DNSQueryProfiledTest.php │ │ │ ├── DNSQueryFailedTest.php │ │ │ └── DNSQueriedTest.php │ │ ├── Traits │ │ │ ├── ProfileableTest.php │ │ │ └── DispatcherTest.php │ │ └── Subscribers │ │ │ └── STDIOSubscriberTest.php │ ├── Resolvers │ │ ├── TestResolver.php │ │ ├── DigTest.php │ │ ├── CachedTest.php │ │ ├── LocalSystemTest.php │ │ └── ResolverAbstractTest.php │ ├── BaseTestAbstract.php │ ├── Entities │ │ ├── DNSRecordTypeTest.php │ │ ├── TXTDataTest.php │ │ ├── NSDataTest.php │ │ ├── IPAddressTest.php │ │ ├── PTRDataTest.php │ │ ├── CNAMEDataTest.php │ │ ├── MXDataTest.php │ │ ├── HostnameTest.php │ │ ├── SRVDataTest.php │ │ ├── CAADataTest.php │ │ ├── SOADataTest.php │ │ ├── DNSRecordTest.php │ │ ├── DataAbstractTest.php │ │ └── DNSRecordCollectionTest.php │ └── Mappers │ │ ├── CloudFlareTest.php │ │ ├── GoogleDNSTest.php │ │ ├── DigTest.php │ │ └── LocalSystemTest.php └── Integration │ ├── ServicesTest.php │ └── BaseTestAbstract.php ├── CODE_OF_CONDUCT.md ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── php-ci.yml │ └── php-code-coverage.yml ├── CONTRIBUTING.md ├── rector.php ├── phpunit.xml.dist ├── LICENSE ├── Makefile ├── composer.json ├── bootstrap └── repl.php ├── churn.yml └── psalm.xml /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | /vendor/ 3 | /coverage/ 4 | /.idea/ 5 | .DS_Store 6 | .churn.cache 7 | composer.lock 8 | .phpunit.result.cache -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | checkMissingIterableValueType: false 3 | checkGenericClassInNonGenericObjectType: false 4 | ignoreErrors: 5 | -------------------------------------------------------------------------------- /src/Entities/EntityAbstract.php: -------------------------------------------------------------------------------- 1 | $this->getMessage(), 13 | 'code' => $this->getCode(), 14 | 'file' => $this->getFile(), 15 | 'line' => $this->getLine(), 16 | 'trace' => $this->getTraceAsString(), 17 | ]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Observability/Performance/Timer.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 15 | } 16 | 17 | protected function getLogger(): LoggerInterface 18 | { 19 | if ($this->logger === null) { 20 | $this->logger = new NullLogger(); 21 | } 22 | 23 | return $this->logger; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Observability/Events/ObservableEventAbstract.php: -------------------------------------------------------------------------------- 1 | $this->toArray(), 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Be Kind 4 | 5 | We're all very different, and we're all human. What an amazing thing! 6 | 7 | Every person is welcome here in so much as they do not seek to harass or dehumanize another one 8 | on this project. 9 | 10 | Always assume best intent. 11 | 12 | Strive for civility, kindness, helpfulness, and humility above all else. 13 | 14 | In the words of the Author Kurt Vonnegut via Mr. Rosewater: 15 | 16 | > Hello babies. Welcome to Earth. It’s hot in the summer and cold in the winter. It’s round and wet and crowded. On the outside, babies, you’ve got a hundred years here. There’s only one rule that I know of, babies—God damn it, you’ve got to be kind. 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/Entities/TXTData.php: -------------------------------------------------------------------------------- 1 | value; 14 | } 15 | 16 | public function getValue(): string 17 | { 18 | return $this->value; 19 | } 20 | 21 | public function toArray(): array 22 | { 23 | return [ 24 | 'value' => $this->value, 25 | ]; 26 | } 27 | 28 | public function __unserialize(array $unserialized): void 29 | { 30 | $this->value = $unserialized['value']; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | First off, thank you for even considering contributing! All you need to contribute is curiousity, a helpful attitude, and a few extra things like: 2 | 3 | - 100% unit test coverage. Test coverage isn't everything, but it's easy. Good test cases are much appreciated. 4 | - Comply with PSR2 coding standards. 5 | - Keep your solution as SOLID and simple as possible. 6 | 7 | Of course I am available to help and am open to your way of doing something. If you want to propose a radically different approach, 8 | the best way to do it is in a PR with examples. It helps clear up any communication misunderstanding around your design. 9 | 10 | Again, thank you so much for being interested in contributing. 11 | 12 | Also, checkout the Makefile for useful goodies to get you up and running, coding, and testing. 13 | 14 | - Christian 15 | -------------------------------------------------------------------------------- /src/Entities/NSData.php: -------------------------------------------------------------------------------- 1 | target; 17 | } 18 | 19 | public function getTarget(): Hostname 20 | { 21 | return $this->target; 22 | } 23 | 24 | public function toArray(): array 25 | { 26 | return [ 27 | 'target' => (string)$this->target, 28 | ]; 29 | } 30 | 31 | public function __unserialize(array $unserialized): void 32 | { 33 | $this->target = new Hostname($unserialized['target']); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Entities/CNAMEData.php: -------------------------------------------------------------------------------- 1 | hostname; 17 | } 18 | 19 | public function getHostname(): Hostname 20 | { 21 | return $this->hostname; 22 | } 23 | 24 | public function toArray(): array 25 | { 26 | return [ 27 | 'hostname' => (string)$this->hostname, 28 | ]; 29 | } 30 | 31 | public function __unserialize(array $unserialized): void 32 | { 33 | $this->hostname = new Hostname($unserialized['hostname']); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Entities/PTRData.php: -------------------------------------------------------------------------------- 1 | hostname; 17 | } 18 | 19 | public function getHostname(): Hostname 20 | { 21 | return $this->hostname; 22 | } 23 | 24 | public function toArray(): array 25 | { 26 | return [ 27 | 'hostname' => (string)$this->hostname, 28 | ]; 29 | } 30 | 31 | public function __unserialize(array $unserialized): void 32 | { 33 | $this->hostname = new Hostname($unserialized['hostname']); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Resolvers/Traits/Time.php: -------------------------------------------------------------------------------- 1 | dateTimeImmutable = $dateTimeImmutable; 14 | } 15 | 16 | public function getTimeStamp(): int 17 | { 18 | return $this->getNewDateTimeImmutable()->getTimestamp(); 19 | } 20 | 21 | private function getNewDateTimeImmutable(): DateTimeImmutable 22 | { 23 | if (!$this->dateTimeImmutable) { 24 | $this->dateTimeImmutable = new DateTimeImmutable(); 25 | } 26 | 27 | return /** @scrutinizer ignore-type */ $this->dateTimeImmutable 28 | ->setTimestamp(\time()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Unit/Exceptions/ExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(JsonSerializable::class, $exception); 19 | 20 | $jsonReady = $exception->jsonSerialize(); 21 | 22 | $this->assertSame('The exception', $jsonReady['message']); 23 | $this->assertSame(123, $jsonReady['code']); 24 | $this->assertTrue(is_int($jsonReady['line'])); 25 | $this->assertTrue(is_string($jsonReady['trace'])); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Entities/Interfaces/DNSRecordInterface.php: -------------------------------------------------------------------------------- 1 | timer = new Timer(); 19 | } 20 | 21 | /** 22 | * @test 23 | */ 24 | public function getsNowAndMicroTime(): void 25 | { 26 | $this->assertGreaterThan(0, $this->timer->getMicroTime()); 27 | $this->assertTrue(is_float($this->timer->getMicroTime())); 28 | 29 | $this->assertInstanceOf(DateTimeInterface::class, $this->timer->now()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Observability/Traits/Profileable.php: -------------------------------------------------------------------------------- 1 | getProfileFactory()->create($transactionName); 15 | } 16 | 17 | public function setProfileFactory(ProfileFactory $profileFactory): void 18 | { 19 | $this->profileFactory = $profileFactory; 20 | } 21 | 22 | private function getProfileFactory(): ProfileFactory 23 | { 24 | if ($this->profileFactory === null) { 25 | $this->profileFactory = new ProfileFactory(); 26 | } 27 | 28 | return $this->profileFactory; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Unit/Resolvers/TestResolver.php: -------------------------------------------------------------------------------- 1 | recordCollection) { 20 | return $this->recordCollection; 21 | } 22 | 23 | if ($this->error) { 24 | throw $this->error; 25 | } 26 | 27 | return new DNSRecordCollection(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | parameters(); 14 | $parameters->set(Option::PATHS, [ 15 | __DIR__ . '/src' 16 | ]); 17 | 18 | // Define what rule sets will be applied 19 | $containerConfigurator->import(LevelSetList::UP_TO_PHP_80); 20 | $containerConfigurator->import(SetList::DEAD_CODE); 21 | $containerConfigurator->import(SetList::CODE_QUALITY); 22 | 23 | // get services (needed for register a single rule) 24 | $services = $containerConfigurator->services(); 25 | 26 | // register a single rule 27 | $services->set(TypedPropertyRector::class); 28 | }; 29 | -------------------------------------------------------------------------------- /.github/workflows/php-ci.yml: -------------------------------------------------------------------------------- 1 | name: PHP CI Run 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | php-versions: ['8.0', '8.1'] 15 | name: PHP ${{ matrix.php-versions }} Test on Ubuntu 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Validate composer.json and composer.lock 20 | run: composer validate --strict 21 | 22 | - name: Cache Composer packages 23 | id: composer-cache 24 | uses: actions/cache@v2 25 | with: 26 | path: vendor 27 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 28 | restore-keys: | 29 | ${{ runner.os }}-php- 30 | 31 | - name: Install dependencies 32 | run: composer install --prefer-dist --no-progress 33 | 34 | - name: Run CI suite for ${{ matrix.php-versions }} 35 | run: make build 36 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src 6 | 7 | 8 | 9 | 10 | ./tests/Unit 11 | 12 | 13 | ./tests/Integration 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Observability/Events/DNSQueryProfiled.php: -------------------------------------------------------------------------------- 1 | profile; 20 | } 21 | 22 | public static function getName(): string 23 | { 24 | return self::NAME; 25 | } 26 | 27 | public function toArray(): array 28 | { 29 | return [ 30 | 'elapsedSeconds' => $this->profile->getElapsedSeconds(), 31 | 'transactionName' => $this->profile->getTransactionName(), 32 | 'peakMemoryUsage' => $this->profile->getPeakMemoryUsage(), 33 | ]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Entities/MXData.php: -------------------------------------------------------------------------------- 1 | priority} {$this->target}"; 17 | } 18 | 19 | public function getTarget(): Hostname 20 | { 21 | return $this->target; 22 | } 23 | 24 | public function getPriority(): int 25 | { 26 | return $this->priority; 27 | } 28 | 29 | public function toArray(): array 30 | { 31 | return [ 32 | 'target' => (string)$this->target, 33 | 'priority' => $this->priority, 34 | ]; 35 | } 36 | 37 | public function __unserialize(array $unserialized): void 38 | { 39 | $this->target = new Hostname($unserialized['target']); 40 | $this->priority = $unserialized['priority']; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Integration/ServicesTest.php: -------------------------------------------------------------------------------- 1 | createLocalSystemDNS(); 17 | 18 | $records = $localDNS->getRecord('google.com', DNS_A); 19 | $this->assertNotEmpty($records); 20 | } 21 | 22 | /** 23 | * @test 24 | */ 25 | public function localDNSServiceProvidesReverseLookup(): void 26 | { 27 | $localDNS = $this->createLocalSystemDNS(); 28 | 29 | $hostname = $localDNS->getHostnameByAddress('127.0.0.1'); 30 | $this->assertSame('localhost', $hostname); 31 | } 32 | 33 | /** 34 | * @test 35 | */ 36 | public function throwsOnReverseLookupFailure(): void 37 | { 38 | $this->expectException(ReverseLookupFailure::class); 39 | $this->createLocalSystemDNS()->getHostnameByAddress('0.0.0.0'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Christian Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /tests/Unit/BaseTestAbstract.php: -------------------------------------------------------------------------------- 1 | assertEquals($serializable, unserialize(serialize($serializable))); 18 | } 19 | 20 | protected function assertArrayableAndEquals(array $expected, Arrayable $arrayable) 21 | { 22 | $this->assertEquals($expected, $arrayable->toArray()); 23 | } 24 | 25 | protected function assertJsonSerializeableAndEquals(array $expected, JsonSerializable $jsonSerializeable) 26 | { 27 | $this->assertEquals($expected, $jsonSerializeable->jsonSerialize()); 28 | } 29 | 30 | protected function assertStringableAndEquals(string $expected, string $stringable) 31 | { 32 | $this->assertSame($expected, $stringable); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | @make dependencies && make dependency-check && make static-analysis && make style-check && make unit-tests && make integration-tests 3 | 4 | dependencies: 5 | @composer install 6 | 7 | unit-tests: 8 | @vendor/bin/paratest -p8 --runner=WrapperRunner --bootstrap=./tests/bootstrap.php --testsuite Unit 9 | 10 | integration-tests: 11 | @vendor/bin/paratest -p8 --runner=WrapperRunner --bootstrap=./tests/bootstrap.php --testsuite Integration 12 | 13 | test-coverage-ci: 14 | @mkdir -p ./build/logs && ./vendor/bin/phpunit -c phpunit.xml.dist --coverage-clover ./build/logs/clover.xml && php vendor/bin/php-coveralls --root_dir=. -v 15 | 16 | test-coverage-html: 17 | @vendor/bin/paratest -p8 --runner=WrapperRunner --bootstrap=./tests/bootstrap.php --coverage-html ./coverage 18 | 19 | style-check: 20 | @vendor/bin/phpcs --standard=PSR12 ./src/* ./tests/* 21 | 22 | dependency-check: 23 | @vendor/bin/composer-require-checker check -vvv ./composer.json 24 | 25 | churn-report: 26 | @vendor/bin/churn run 27 | 28 | static-analysis: 29 | @vendor/bin/phpstan analyze --level=8 ./src && ./vendor/bin/psalm --show-info=false 30 | 31 | style-fix: 32 | @vendor/bin/phpcbf --standard=PSR12 ./src ./tests 33 | 34 | repl: 35 | @vendor/bin/psysh bootstrap/repl.php -------------------------------------------------------------------------------- /src/Mappers/CloudFlare.php: -------------------------------------------------------------------------------- 1 | fields['type']); 21 | $IPAddress = (isset($this->fields[self::DATA]) && IPAddress::isValid($this->fields[self::DATA])) 22 | ? $this->fields[self::DATA] 23 | : null; 24 | 25 | $value = (isset($this->fields[self::DATA]) && !$IPAddress) 26 | ? str_ireplace('"', '', (string)$this->fields[self::DATA]) 27 | : null; 28 | 29 | return DNSRecord::createFromPrimitives( 30 | (string)$type, 31 | $this->fields['name'], 32 | $this->fields['TTL'], 33 | $IPAddress, 34 | 'IN', 35 | $value 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/php-code-coverage.yml: -------------------------------------------------------------------------------- 1 | name: PHP Code Coverage 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | php-versions: ['8.0'] 15 | name: PHP Code Coverage 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Validate composer.json and composer.lock 20 | run: composer validate --strict 21 | 22 | - name: Cache Composer packages 23 | id: composer-cache 24 | uses: actions/cache@v2 25 | with: 26 | path: vendor 27 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 28 | restore-keys: | 29 | ${{ runner.os }}-php- 30 | 31 | - name: Install dependencies 32 | run: composer install --prefer-dist --no-progress 33 | 34 | - name: Run test coverage and send to coveralls 35 | run: make test-coverage-ci 36 | env: 37 | GITHUB_RUN_ID: ${{ github.run_id }} 38 | GITHUB_EVENT_NAME: ${{ github.event_name }} 39 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | XDEBUG_MODE: coverage 41 | -------------------------------------------------------------------------------- /src/Mappers/GoogleDNS.php: -------------------------------------------------------------------------------- 1 | fields['type']); 22 | $IPAddress = (isset($this->fields[self::DATA]) && IPAddress::isValid($this->fields[self::DATA])) 23 | ? $this->fields[self::DATA] 24 | : null; 25 | 26 | $value = (isset($this->fields[self::DATA]) && !$IPAddress) 27 | ? str_ireplace('"', '', (string)$this->fields[self::DATA]) 28 | : null; 29 | 30 | return DNSRecord::createFromPrimitives( 31 | (string)$type, 32 | $this->fields['name'], 33 | $this->fields['TTL'], 34 | $IPAddress, 35 | 'IN', 36 | $value 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Services/LocalSystemDNS.php: -------------------------------------------------------------------------------- 1 | time = $time ?? new Timer(); 23 | } 24 | 25 | public function startTransaction(): void 26 | { 27 | $this->startTime = $this->time->getMicroTime(); 28 | } 29 | 30 | public function endTransaction(): void 31 | { 32 | $this->stopTime = $this->time->getMicroTime(); 33 | } 34 | 35 | public function getTransactionName(): string 36 | { 37 | return $this->transactionName; 38 | } 39 | 40 | public function getElapsedSeconds(): float 41 | { 42 | return $this->stopTime - $this->startTime; 43 | } 44 | 45 | public function samplePeakMemoryUsage(): void 46 | { 47 | $this->peakMemoryUsage = memory_get_peak_usage(); 48 | } 49 | 50 | public function getPeakMemoryUsage(): int 51 | { 52 | return $this->peakMemoryUsage; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Entities/SRVData.php: -------------------------------------------------------------------------------- 1 | priority} {$this->weight} {$this->port} {$this->target}"; 17 | } 18 | 19 | public function getPriority(): int 20 | { 21 | return $this->priority; 22 | } 23 | 24 | public function getWeight(): int 25 | { 26 | return $this->weight; 27 | } 28 | 29 | public function getPort(): int 30 | { 31 | return $this->port; 32 | } 33 | 34 | public function getTarget(): Hostname 35 | { 36 | return $this->target; 37 | } 38 | 39 | public function toArray(): array 40 | { 41 | return [ 42 | 'priority' => $this->priority, 43 | 'weight' => $this->weight, 44 | 'port' => $this->port, 45 | 'target' => (string)$this->target, 46 | ]; 47 | } 48 | 49 | public function __unserialize(array $unserialized): void 50 | { 51 | $this->priority = $unserialized['priority']; 52 | $this->weight = $unserialized['weight']; 53 | $this->port = $unserialized['port']; 54 | $this->target = new Hostname($unserialized['target']); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Observability/Events/DNSQueryFailed.php: -------------------------------------------------------------------------------- 1 | resolver; 26 | } 27 | 28 | public function getHostName(): Hostname 29 | { 30 | return $this->hostname; 31 | } 32 | 33 | public function getRecordType(): DNSRecordType 34 | { 35 | return $this->recordType; 36 | } 37 | 38 | public function getError(): Exception 39 | { 40 | return $this->error; 41 | } 42 | 43 | public static function getName(): string 44 | { 45 | return self::NAME; 46 | } 47 | 48 | public function toArray(): array 49 | { 50 | return [ 51 | 'resolver' => $this->resolver->getName(), 52 | 'hostname' => (string)$this->hostname, 53 | 'type' => (string)$this->recordType, 54 | 'error' => $this->error, 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Observability/Subscribers/STDIOSubscriber.php: -------------------------------------------------------------------------------- 1 | 'onDNSQueryFailed', 24 | DNSQueried::getName() => 'onDNSQueried', 25 | DNSQueryProfiled::getName() => 'onDNSQueryProfiled', 26 | ]; 27 | } 28 | 29 | public function onDNSQueryFailed(ObservableEventAbstract $event): void 30 | { 31 | $this->STDERR->fwrite(json_encode($event, JSON_PRETTY_PRINT) . PHP_EOL); 32 | } 33 | 34 | public function onDNSQueried(ObservableEventAbstract $event): void 35 | { 36 | $this->STDOUT->fwrite(json_encode($event, JSON_PRETTY_PRINT) . PHP_EOL); 37 | } 38 | 39 | public function onDNSQueryProfiled(ObservableEventAbstract $event): void 40 | { 41 | $this->STDOUT->fwrite(json_encode($event, JSON_PRETTY_PRINT) . PHP_EOL); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remotelyliving/php-dns", 3 | "description": "A php library for abstracting DNS querying", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "chthomas", 9 | "email": "christian.h.thomas@me.com" 10 | } 11 | ], 12 | "minimum-stability": "stable", 13 | "require": { 14 | "php": ">=8.0", 15 | "ext-json": "*", 16 | "ext-filter": "*", 17 | "ext-intl": "*", 18 | "ext-mbstring": "*", 19 | "guzzlehttp/guzzle": "^7.0 || ^6.0", 20 | "psr/cache": "^1.0 || ^2.0", 21 | "symfony/event-dispatcher": "^6.0 || ^5.0 || ^4.0 || ^3.0", 22 | "psr/log": "^1.0 || ^2.0 || ^3.0", 23 | "guzzlehttp/promises": "^1.3", 24 | "spatie/dns": "^2.0" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^9.0", 28 | "maglnet/composer-require-checker": "@stable", 29 | "squizlabs/php_codesniffer": "^3.3", 30 | "psy/psysh": "^0.9.9", 31 | "php-coveralls/php-coveralls": "^2.1", 32 | "symfony/cache": "^4.3", 33 | "vimeo/psalm": "^4.10", 34 | "rector/rector": "^0.12.8", 35 | "bmitch/churn-php": "^1.5", 36 | "brianium/paratest": "^6.4" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "RemotelyLiving\\PHPDNS\\" : "src/" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "RemotelyLiving\\PHPDNS\\Tests\\" : "tests/" 46 | } 47 | }, 48 | "config": { 49 | "allow-plugins": { 50 | "composer/package-versions-deprecated": true 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Unit/Observability/Events/ObservableEventAbstractTest.php: -------------------------------------------------------------------------------- 1 | event = new class extends ObservableEventAbstract 19 | { 20 | public static function getName(): string 21 | { 22 | return 'the name'; 23 | } 24 | 25 | public function toArray(): array 26 | { 27 | return ['beep' => 'boop']; 28 | } 29 | }; 30 | } 31 | 32 | /** 33 | * @test 34 | */ 35 | public function getsName(): void 36 | { 37 | $this->assertSame('the name', $this->event::getName()); 38 | } 39 | 40 | /** 41 | * @test 42 | */ 43 | public function isArrayable(): void 44 | { 45 | $this->assertInstanceOf(Arrayable::class, $this->event); 46 | $this->assertEquals(['beep' => 'boop'], $this->event->toArray()); 47 | } 48 | 49 | /** 50 | * @test 51 | */ 52 | public function isJsonSerializable(): void 53 | { 54 | $this->assertInstanceOf(JsonSerializable::class, $this->event); 55 | $this->assertEquals(['the name' => ['beep' => 'boop']], $this->event->jsonSerialize()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Entities/IPAddress.php: -------------------------------------------------------------------------------- 1 | IPAddress = $IPAddress; 26 | } 27 | 28 | public function __toString(): string 29 | { 30 | return $this->IPAddress; 31 | } 32 | 33 | public static function isValid(string $IPAddress): bool 34 | { 35 | return (bool) filter_var($IPAddress, FILTER_VALIDATE_IP); 36 | } 37 | 38 | public static function createFromString(string $IPAddress): IPAddress 39 | { 40 | return new self($IPAddress); 41 | } 42 | 43 | public function equals(IPAddress $IPAddress): bool 44 | { 45 | return $this->IPAddress === (string)$IPAddress; 46 | } 47 | 48 | public function getIPAddress(): string 49 | { 50 | return $this->IPAddress; 51 | } 52 | 53 | public function isIPv6(): bool 54 | { 55 | return (bool) filter_var($this->IPAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); 56 | } 57 | 58 | public function isIPv4(): bool 59 | { 60 | return (bool) filter_var($this->IPAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /bootstrap/repl.php: -------------------------------------------------------------------------------- 1 | addSubscriber($IOSubscriber); 29 | 30 | $googleDNSResolver = new GoogleDNS(); 31 | $googleDNSResolver->addSubscriber($IOSubscriber); 32 | 33 | $cloudFlareResolver = new CloudFlare(); 34 | $cloudFlareResolver->addSubscriber($IOSubscriber); 35 | 36 | $digResolver = new \RemotelyLiving\PHPDNS\Resolvers\Dig(new Dns(), new Dig()); 37 | $digResolver->addSubscriber($IOSubscriber); 38 | 39 | $chainResolver = new Chain($cloudFlareResolver, $googleDNSResolver, $localSystemResolver); 40 | $cachedResolver = new Cached(new FilesystemAdapter(), $chainResolver); 41 | $cachedResolver->addSubscriber($IOSubscriber); -------------------------------------------------------------------------------- /tests/Unit/Entities/DNSRecordTypeTest.php: -------------------------------------------------------------------------------- 1 | assertSame($type, (string)$createdType); 20 | $this->assertEquals($createdType, DNSRecordType::createFromInt($createdType->toInt())); 21 | } 22 | } 23 | 24 | /** 25 | * @test 26 | */ 27 | public function onlyAllowsValidTypesFromStrings(): void 28 | { 29 | $this->expectException(InvalidArgumentException::class); 30 | 31 | DNSRecordType::createFromString('SDFSF'); 32 | } 33 | 34 | /** 35 | * @test 36 | */ 37 | public function onlyAllowsValidTypesFromIntCodes(): void 38 | { 39 | $this->expectException(InvalidArgumentException::class); 40 | 41 | DNSRecordType::createFromInt(-100); 42 | } 43 | 44 | /** 45 | * @test 46 | */ 47 | public function comparesItself(): void 48 | { 49 | $aRecord = DNSRecordType::createFromString('A'); 50 | $cnameRecord = DNSRecordType::createFromString('CNAME'); 51 | $otherARecord = DNSRecordType::createFromString('A'); 52 | 53 | $this->assertFalse($aRecord->equals($cnameRecord)); 54 | $this->assertTrue($otherARecord->equals($aRecord)); 55 | 56 | $this->assertTrue($aRecord->isA('A')); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Resolvers/LocalSystem.php: -------------------------------------------------------------------------------- 1 | systemDNS = $systemDNS ?? new LocalDNSService(); 23 | $this->mapper = $mapper ?? new LocalMapper(); 24 | } 25 | 26 | public function getHostnameByAddress(string $IPAddress): Hostname 27 | { 28 | $result = $this->systemDNS->getHostnameByAddress((string)(new IPAddress($IPAddress))); 29 | 30 | return Hostname::createFromString($result); 31 | } 32 | 33 | protected function doQuery(Hostname $hostname, DNSRecordType $recordType): DNSRecordCollection 34 | { 35 | $results = $this->systemDNS->getRecord( 36 | $hostname->getHostnameWithoutTrailingDot(), // dns_get_record doesn't like trailing dot as much! 37 | $this->mapper->getTypeCodeFromType($recordType) 38 | ); 39 | 40 | $collection = new DNSRecordCollection(); 41 | 42 | foreach ($results as $result) { 43 | $collection[] = $this->mapper->mapFields($result)->toDNSRecord(); 44 | } 45 | 46 | return $collection; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Entities/CAAData.php: -------------------------------------------------------------------------------- 1 | value = ($value) 20 | ? $this->normalizeValue($value) 21 | : null; 22 | } 23 | 24 | public function __toString(): string 25 | { 26 | return "{$this->flags} {$this->tag} \"{$this->value}\""; 27 | } 28 | 29 | public function __unserialize(array $unserialized): void 30 | { 31 | $this->flags = $unserialized['flags']; 32 | $this->tag = $unserialized['tag']; 33 | $this->value = $unserialized['value']; 34 | } 35 | 36 | public function getFlags(): int 37 | { 38 | return $this->flags; 39 | } 40 | 41 | public function getTag(): string 42 | { 43 | return $this->tag; 44 | } 45 | 46 | public function getValue(): ?string 47 | { 48 | return $this->value; 49 | } 50 | 51 | public function toArray(): array 52 | { 53 | return [ 54 | 'flags' => $this->flags, 55 | 'tag' => $this->tag, 56 | 'value' => $this->value, 57 | ]; 58 | } 59 | 60 | private function normalizeValue(string $value): string 61 | { 62 | $normalized = trim(str_ireplace('"', '', $value)); 63 | 64 | if (preg_match('/\s/m', $normalized)) { 65 | throw new Exceptions\InvalidArgumentException("$value is not a valid CAA value"); 66 | } 67 | 68 | return $normalized; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Resolvers/Interfaces/DNSQuery.php: -------------------------------------------------------------------------------- 1 | profile = new Profile('transactionName'); 23 | $this->profileFactory = $this->createMock(ProfileFactory::class); 24 | $this->profileFactory->method('create') 25 | ->with('transactionName') 26 | ->willReturn($this->profile); 27 | 28 | $this->profileableClass = new class { 29 | use Profileable; 30 | }; 31 | } 32 | 33 | /** 34 | * @test 35 | */ 36 | public function createsProfiles(): void 37 | { 38 | $profile = $this->profileableClass->createProfile('name'); 39 | $this->assertInstanceOf(Profile::class, $profile); 40 | $this->assertSame('name', $profile->getTransactionName()); 41 | } 42 | 43 | /** 44 | * @test 45 | */ 46 | public function setsAProfileFactory(): void 47 | { 48 | $this->profileableClass->setProfileFactory($this->profileFactory); 49 | $profile = $this->profileableClass->createProfile('transactionName'); 50 | 51 | $this->assertInstanceOf(Profile::class, $profile); 52 | $this->assertSame('transactionName', $profile->getTransactionName()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Unit/Entities/TXTDataTest.php: -------------------------------------------------------------------------------- 1 | TXTData = new TXTData($this->value); 22 | } 23 | 24 | /** 25 | * @test 26 | */ 27 | public function knowsIfEquals(): void 28 | { 29 | $this->assertTrue($this->TXTData->equals($this->TXTData)); 30 | $this->assertFalse($this->TXTData->equals(new TXTData('beep'))); 31 | } 32 | 33 | /** 34 | * @test 35 | */ 36 | public function isArrayable(): void 37 | { 38 | $this->assertArrayableAndEquals(['value' => $this->value], $this->TXTData); 39 | } 40 | 41 | /** 42 | * @test 43 | */ 44 | public function isJsonSerializeable(): void 45 | { 46 | $this->assertJsonSerializeableAndEquals(['value' => $this->value], $this->TXTData); 47 | } 48 | 49 | /** 50 | * @test 51 | */ 52 | public function isSerializable(): void 53 | { 54 | $this->assertSerializable($this->TXTData); 55 | $this->assertEquals($this->TXTData, unserialize(serialize($this->TXTData))); 56 | } 57 | 58 | /** 59 | * @test 60 | */ 61 | public function isStringable(): void 62 | { 63 | $this->assertStringableAndEquals('boop', $this->TXTData); 64 | } 65 | 66 | /** 67 | * @test 68 | */ 69 | public function hasBasicGetters(): void 70 | { 71 | $this->assertSame($this->value, $this->TXTData->getValue()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /churn.yml: -------------------------------------------------------------------------------- 1 | # The maximum number of files to display in the results table. 2 | # Default: 10 3 | filesToShow: 20 4 | 5 | # The minimum score a file need to display in the results table. 6 | # Disabled if null. 7 | # Default: 0.1 8 | minScoreToShow: 0 9 | 10 | # The command returns an 1 exit code if the highest score is greater than the threshold. 11 | # Disabled if null. 12 | # Default: null 13 | maxScoreThreshold: 0.9 14 | 15 | # The number of parallel jobs to use when processing files. 16 | # Default: 10 17 | parallelJobs: 10 18 | 19 | # How far back in the VCS history to count the number of commits to a file 20 | # Can be a human readable date like 'One week ago' or a date like '2017-07-12' 21 | # Default: '10 Years ago' 22 | commitsSince: One year ago 23 | 24 | # Files to ignore when processing. The full path to the file relative to the root of your project is required. 25 | # Also supports regular expressions. 26 | # Default: All PHP files in the path provided to churn-php are processed. 27 | #filesToIgnore: 28 | 29 | # File extensions to use when processing. 30 | # Default: php 31 | fileExtensions: 32 | - php 33 | - inc 34 | 35 | # This list is used only if there is no argument when running churn. 36 | # Default: 37 | directoriesToScan: 38 | - src 39 | - tests/ 40 | 41 | # List of user-defined hooks. 42 | # They can be referenced by their full qualified class name if churn has access to the autoloader. 43 | # Otherwise the file path can be used as well. 44 | # See below the section about hooks for more details. 45 | # Default: 46 | #hooks: 47 | 48 | # The version control system used for your project. 49 | # Accepted values: fossil, git, mercurial, subversion, none 50 | # Default: git 51 | vcs: git 52 | 53 | # The path of the cache file. It doesn't need to exist before running churn. 54 | # Disabled if null. 55 | # Default: null 56 | cachePath: .churn.cache 57 | -------------------------------------------------------------------------------- /tests/Unit/Observability/Events/DNSQueryProfiledTest.php: -------------------------------------------------------------------------------- 1 | profile = $this->createMock(ProfileInterface::class); 22 | $this->DNSQueryProfiled = new DNSQueryProfiled($this->profile); 23 | } 24 | 25 | /** 26 | * @test 27 | */ 28 | public function hasBasicGetters(): void 29 | { 30 | $this->assertSame($this->profile, $this->DNSQueryProfiled->getProfile()); 31 | $this->assertSame(DNSQueryProfiled::NAME, $this->DNSQueryProfiled::getName()); 32 | } 33 | 34 | /** 35 | * @test 36 | */ 37 | public function isArrayable(): void 38 | { 39 | $this->profile->method('getElapsedSeconds') 40 | ->willReturn(100.1); 41 | 42 | $this->profile->method('getTransactionName') 43 | ->willReturn('transactionName'); 44 | 45 | $this->profile->method('getPeakMemoryUsage') 46 | ->willReturn(123); 47 | 48 | $expected = [ 49 | 'elapsedSeconds' => 100.1, 50 | 'transactionName' => 'transactionName', 51 | 'peakMemoryUsage' => 123, 52 | ]; 53 | 54 | $this->assertInstanceOf(Arrayable::class, $this->DNSQueryProfiled); 55 | $this->assertEquals($expected, $this->DNSQueryProfiled->toArray()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/Unit/Entities/NSDataTest.php: -------------------------------------------------------------------------------- 1 | target = new Hostname('google.com'); 23 | $this->NSData = new NSData($this->target); 24 | } 25 | 26 | /** 27 | * @test 28 | */ 29 | public function knowsIfEquals(): void 30 | { 31 | $this->assertTrue($this->NSData->equals($this->NSData)); 32 | $this->assertFalse($this->NSData->equals(new NSData(new Hostname('boop.com')))); 33 | } 34 | 35 | /** 36 | * @test 37 | */ 38 | public function isArrayable(): void 39 | { 40 | $this->assertArrayableAndEquals(['target' => (string)$this->target], $this->NSData); 41 | } 42 | 43 | /** 44 | * @test 45 | */ 46 | public function isJsonSerializeable(): void 47 | { 48 | $this->assertJsonSerializeableAndEquals(['target' => (string)$this->target], $this->NSData); 49 | } 50 | 51 | /** 52 | * @test 53 | */ 54 | public function isSerializable(): void 55 | { 56 | $this->assertSerializable($this->NSData); 57 | $this->assertEquals($this->NSData, unserialize(serialize($this->NSData))); 58 | } 59 | 60 | /** 61 | * @test 62 | */ 63 | public function isStringable(): void 64 | { 65 | $this->assertStringableAndEquals('google.com.', $this->NSData); 66 | } 67 | 68 | /** 69 | * @test 70 | */ 71 | public function hasBasicGetters(): void 72 | { 73 | $this->assertSame($this->target, $this->NSData->getTarget()); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Unit/Entities/IPAddressTest.php: -------------------------------------------------------------------------------- 1 | IPAddress = new IPAddress('127.0.0.1'); 18 | } 19 | 20 | /** 21 | * @test 22 | */ 23 | public function hasBasicGettersAndIsStringy(): void 24 | { 25 | $this->assertSame('127.0.0.1', (string)$this->IPAddress); 26 | $this->assertSame('127.0.0.1', $this->IPAddress->getIPAddress()); 27 | } 28 | 29 | /** 30 | * @test 31 | */ 32 | public function testsForEquality(): void 33 | { 34 | $IPAddress1 = IPAddress::createFromString('127.0.0.1'); 35 | $IPAddress2 = IPAddress::createFromString('192.168.1.1'); 36 | $IPAddress3 = IPAddress::createFromString('127.0.0.1'); 37 | 38 | $this->assertFalse($IPAddress1->equals($IPAddress2)); 39 | $this->assertTrue($IPAddress3->equals($IPAddress1)); 40 | } 41 | 42 | /** 43 | * @test 44 | */ 45 | public function doesNotAllowInvalidHostNames(): void 46 | { 47 | $this->expectException(InvalidArgumentException::class); 48 | $this->assertFalse(IPAddress::isValid('lskjf')); 49 | IPAddress::createFromString('127.1.1'); 50 | } 51 | 52 | /** 53 | * @test 54 | */ 55 | public function knowsWhatVersionItIs(): void 56 | { 57 | $IPv4 = IPAddress::createFromString('127.0.0.1'); 58 | $IPv6 = IPAddress::createFromString('2001:0db8:85a3:0000:0000:8a2e:0370:7334'); 59 | 60 | $this->assertTrue($IPv4->isIPv4()); 61 | $this->assertFalse($IPv4->isIPv6()); 62 | $this->assertTrue($IPv6->isIPv6()); 63 | $this->assertFalse($IPv6->isIPv4()); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Unit/Observability/Performance/ProfileTest.php: -------------------------------------------------------------------------------- 1 | time = $this->createMock(Time::class); 23 | $this->time->method('getMicroTime') 24 | ->willReturnOnConsecutiveCalls(1.0, 5.0); 25 | 26 | $this->factory = new ProfileFactory(); 27 | $this->profile = new Profile('transactionName', $this->time); 28 | } 29 | 30 | /** 31 | * @test 32 | */ 33 | public function timesTransactions(): void 34 | { 35 | $this->profile->startTransaction(); 36 | $this->profile->endTransaction(); 37 | 38 | $this->assertSame(4.0, $this->profile->getElapsedSeconds()); 39 | } 40 | 41 | /** 42 | * @test 43 | */ 44 | public function getsPeakMemoryUsage(): void 45 | { 46 | $this->profile->samplePeakMemoryUsage(); 47 | $this->assertSame($this->profile->getPeakMemoryUsage(), memory_get_peak_usage()); 48 | } 49 | 50 | /** 51 | * @test 52 | */ 53 | public function getsATransactionName(): void 54 | { 55 | $this->assertSame('transactionName', $this->profile->getTransactionName()); 56 | } 57 | 58 | /** 59 | * @test 60 | */ 61 | public function hasAnAccompanyingFactory(): void 62 | { 63 | $this->assertEquals(new Profile('transactionName'), $this->factory->create('transactionName')); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Unit/Entities/PTRDataTest.php: -------------------------------------------------------------------------------- 1 | hostname = new Hostname('google.com'); 20 | $this->PTRData = new PTRData($this->hostname); 21 | } 22 | 23 | /** 24 | * @test 25 | */ 26 | public function knowsIfEquals(): void 27 | { 28 | $this->assertTrue($this->PTRData->equals($this->PTRData)); 29 | $this->assertFalse($this->PTRData->equals(new PTRData(new Hostname('boop.com')))); 30 | } 31 | 32 | /** 33 | * @test 34 | */ 35 | public function isArrayable(): void 36 | { 37 | $this->assertArrayableAndEquals( 38 | ['hostname' => (string)$this->hostname], 39 | $this->PTRData 40 | ); 41 | } 42 | 43 | /** 44 | * @test 45 | */ 46 | public function isJsonSerializable(): void 47 | { 48 | $this->assertJsonSerializeableAndEquals( 49 | ['hostname' => (string)$this->hostname], 50 | $this->PTRData 51 | ); 52 | } 53 | 54 | /** 55 | * @test 56 | */ 57 | public function isSerializable(): void 58 | { 59 | $this->assertSerializable($this->PTRData); 60 | } 61 | 62 | /** 63 | * @test 64 | */ 65 | public function isStringable(): void 66 | { 67 | $this->assertStringableAndEquals('google.com.', $this->PTRData); 68 | $this->assertEquals($this->PTRData, unserialize(serialize($this->PTRData))); 69 | } 70 | 71 | /** 72 | * @test 73 | */ 74 | public function hasBasicGetters(): void 75 | { 76 | $this->assertSame($this->hostname, $this->PTRData->getHostname()); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Observability/Events/DNSQueried.php: -------------------------------------------------------------------------------- 1 | recordCollection = $recordCollection ?? new DNSRecordCollection(); 24 | } 25 | 26 | public function getResolver(): Resolver 27 | { 28 | return $this->resolver; 29 | } 30 | 31 | public function getHostname(): Hostname 32 | { 33 | return $this->hostname; 34 | } 35 | 36 | public function getRecordType(): DNSRecordType 37 | { 38 | return $this->recordType; 39 | } 40 | 41 | public function getRecordCollection(): DNSRecordCollection 42 | { 43 | return $this->recordCollection; 44 | } 45 | 46 | public static function getName(): string 47 | { 48 | return self::NAME; 49 | } 50 | 51 | public function toArray(): array 52 | { 53 | return [ 54 | 'resolver' => $this->resolver->getName(), 55 | 'hostname' => (string)$this->hostname, 56 | 'type' => (string)$this->recordType, 57 | 'records' => $this->formatCollection($this->recordCollection), 58 | 'empty' => $this->recordCollection->isEmpty(), 59 | ]; 60 | } 61 | 62 | private function formatCollection(DNSRecordCollection $recordCollection): array 63 | { 64 | $formatted = []; 65 | 66 | foreach ($recordCollection as $record) { 67 | if ($record !== null) { 68 | $formatted[] = $record->toArray(); 69 | } 70 | } 71 | 72 | return $formatted; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/Unit/Entities/CNAMEDataTest.php: -------------------------------------------------------------------------------- 1 | hostname = new Hostname('google.com'); 23 | $this->CNAMEData = new CNAMEData($this->hostname); 24 | } 25 | 26 | /** 27 | * @test 28 | */ 29 | public function knowsIfEquals(): void 30 | { 31 | $this->assertTrue($this->CNAMEData->equals($this->CNAMEData)); 32 | $this->assertFalse($this->CNAMEData->equals(new CNAMEData(new Hostname('boop.com')))); 33 | } 34 | 35 | /** 36 | * @test 37 | */ 38 | public function isArrayable(): void 39 | { 40 | $this->assertArrayableAndEquals( 41 | ['hostname' => (string)$this->hostname], 42 | $this->CNAMEData 43 | ); 44 | } 45 | 46 | /** 47 | * @test 48 | */ 49 | public function isJsonSerializable(): void 50 | { 51 | $this->assertJsonSerializeableAndEquals( 52 | ['hostname' => (string)$this->hostname], 53 | $this->CNAMEData 54 | ); 55 | } 56 | 57 | /** 58 | * @test 59 | */ 60 | public function isSerializable(): void 61 | { 62 | $this->assertSerializable($this->CNAMEData); 63 | } 64 | 65 | /** 66 | * @test 67 | */ 68 | public function isStringable(): void 69 | { 70 | $this->assertStringableAndEquals('google.com.', $this->CNAMEData); 71 | $this->assertEquals($this->CNAMEData, unserialize(serialize($this->CNAMEData))); 72 | } 73 | 74 | /** 75 | * @test 76 | */ 77 | public function hasBasicGetters(): void 78 | { 79 | $this->assertSame($this->hostname, $this->CNAMEData->getHostname()); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/Unit/Mappers/CloudFlareTest.php: -------------------------------------------------------------------------------- 1 | 1, 'TTL' => 343, 'data' => '127.0.0.1', 'name' => 'yelp.com.', 16 | ], 17 | [ 18 | 'type' => 5, 'TTL' => 343, 'name' => 'google.com.', 19 | ], 20 | [ 21 | 'type' => 38, 'TTL' => 343, 'name' => 'google.com.', 22 | ], 23 | ]; 24 | 25 | protected function setUp(): void 26 | { 27 | parent::setUp(); 28 | 29 | $this->mapper = new CloudFlare(); 30 | } 31 | 32 | /** 33 | * @test 34 | */ 35 | public function mapsACloudFlareDNSRecordVariants(): void 36 | { 37 | $mappedRecord = $this->mapper->mapFields([ 38 | 'type' => 1, 39 | 'TTL' => 365, 40 | 'data' => '127.0.0.1', 41 | 'name' => 'facebook.com.', 42 | ])->toDNSRecord(); 43 | 44 | $this->assertEquals( 45 | DNSRecord::createFromPrimitives('A', 'facebook.com', 365, '127.0.0.1'), 46 | $mappedRecord 47 | ); 48 | 49 | $mappedRecord = $this->mapper->mapFields([ 50 | 'type' => 16, 51 | 'TTL' => 365, 52 | 'name' => 'facebook.com.', 53 | 'data' => '"txtval"', 54 | ])->toDNSRecord(); 55 | 56 | $this->assertEquals( 57 | DNSRecord::createFromPrimitives('TXT', 'facebook.com', 365, null, 'IN', 'txtval'), 58 | $mappedRecord 59 | ); 60 | } 61 | 62 | /** 63 | * @test 64 | */ 65 | public function mapsAllKindsOfGoogleDNSRecordVariants(): void 66 | { 67 | foreach (self::CLOUD_FLARE_DNS_FORMAT as $record) { 68 | $mappedRecord = $this->mapper->mapFields($record)->toDNSRecord(); 69 | $this->assertInstanceOf(DNSRecord::class, $mappedRecord); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Entities/Hostname.php: -------------------------------------------------------------------------------- 1 | normalizeHostName($hostname); 24 | 25 | if (filter_var($hostname, FILTER_VALIDATE_DOMAIN) !== $hostname) { 26 | throw new InvalidArgumentException("{$hostname} is not a valid hostname"); 27 | } 28 | 29 | $this->hostname = $hostname; 30 | } 31 | 32 | public function __toString(): string 33 | { 34 | return $this->hostname; 35 | } 36 | 37 | public function equals(Hostname $hostname): bool 38 | { 39 | return $this->hostname === (string)$hostname; 40 | } 41 | 42 | public static function createFromString(string $hostname): Hostname 43 | { 44 | return new self($hostname); 45 | } 46 | 47 | public function getHostName(): string 48 | { 49 | return $this->hostname; 50 | } 51 | 52 | public function getHostnameWithoutTrailingDot(): string 53 | { 54 | return substr($this->hostname, 0, -1); 55 | } 56 | 57 | public function isPunycoded(): bool 58 | { 59 | return $this->toUTF8() !== $this->hostname; 60 | } 61 | 62 | public function toUTF8(): string 63 | { 64 | return (string)idn_to_utf8($this->hostname, IDNA_ERROR_PUNYCODE, INTL_IDNA_VARIANT_UTS46); 65 | } 66 | 67 | private static function punyCode(string $hostname): string 68 | { 69 | return (string)idn_to_ascii($hostname, IDNA_ERROR_PUNYCODE, INTL_IDNA_VARIANT_UTS46); 70 | } 71 | 72 | private function normalizeHostName(string $hostname): string 73 | { 74 | $hostname = self::punyCode(mb_strtolower(trim($hostname))); 75 | 76 | if (!str_ends_with($hostname, '.')) { 77 | return "{$hostname}."; 78 | } 79 | 80 | return $hostname; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Unit/Entities/MXDataTest.php: -------------------------------------------------------------------------------- 1 | target = new Hostname('google.com'); 25 | $this->MXData = new MXData($this->target, $this->priority); 26 | } 27 | 28 | /** 29 | * @test 30 | */ 31 | public function knowsIfEquals(): void 32 | { 33 | $this->assertTrue($this->MXData->equals($this->MXData)); 34 | $this->assertFalse($this->MXData->equals(new MXData(new Hostname('boop.com'), 60))); 35 | } 36 | 37 | /** 38 | * @test 39 | */ 40 | public function isArrayable(): void 41 | { 42 | $this->assertArrayableAndEquals( 43 | ['target' => (string)$this->target, 'priority' => $this->priority], 44 | $this->MXData 45 | ); 46 | } 47 | 48 | /** 49 | * @test 50 | */ 51 | public function isJsonSerializeable(): void 52 | { 53 | $this->assertJsonSerializeableAndEquals( 54 | ['target' => (string)$this->target, 'priority' => $this->priority], 55 | $this->MXData 56 | ); 57 | } 58 | 59 | /** 60 | * @test 61 | */ 62 | public function isSerializable(): void 63 | { 64 | $this->assertSerializable($this->MXData); 65 | $this->assertEquals($this->MXData, unserialize(serialize($this->MXData))); 66 | } 67 | 68 | /** 69 | * @test 70 | */ 71 | public function isStringable(): void 72 | { 73 | $this->assertStringableAndEquals('60 google.com.', $this->MXData); 74 | } 75 | 76 | /** 77 | * @test 78 | */ 79 | public function hasBasicGetters(): void 80 | { 81 | $this->assertSame($this->target, $this->MXData->getTarget()); 82 | $this->assertSame($this->priority, $this->MXData->getPriority()); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Observability/Traits/Dispatcher.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 22 | } 23 | 24 | public function addSubscriber(EventSubscriberInterface $subscriber): void 25 | { 26 | $this->getDispatcher()->addSubscriber($subscriber); 27 | } 28 | 29 | public function addListener(string $eventName, callable $listener, int $priority = 0): void 30 | { 31 | $this->getDispatcher()->addListener($eventName, $listener, $priority); 32 | } 33 | 34 | public function dispatch(ObservableEventAbstract $event): void 35 | { 36 | call_user_func_array([$this->getDispatcher(), 'dispatch'], $this->getOrderedDispatcherArguments($event)); 37 | } 38 | 39 | private function getOrderedDispatcherArguments(ObservableEventAbstract $event): array 40 | { 41 | $reflection = new ReflectionClass($this->getDispatcher()); 42 | $args = []; 43 | 44 | foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { 45 | if ($method->getName() !== 'dispatch') { 46 | continue; 47 | } 48 | // @codeCoverageIgnoreStart 49 | foreach ($method->getParameters() as $parameter) { 50 | $args = ($parameter->getName() === 'event') 51 | ? [$event, $event::getName()] 52 | : [$event::getName(), $event]; 53 | break; 54 | } 55 | // @codeCoverageIgnoreEnd 56 | } 57 | 58 | return $args; 59 | } 60 | 61 | private function getDispatcher(): EventDispatcherInterface 62 | { 63 | if ($this->dispatcher === null) { 64 | $this->dispatcher = new EventDispatcher(); 65 | } 66 | 67 | return $this->dispatcher; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Entities/SOAData.php: -------------------------------------------------------------------------------- 1 | toArray()); 30 | } 31 | 32 | public function getMname(): Hostname 33 | { 34 | return $this->mname; 35 | } 36 | 37 | public function getRname(): Hostname 38 | { 39 | return $this->rname; 40 | } 41 | 42 | public function getSerial(): int 43 | { 44 | return $this->serial; 45 | } 46 | 47 | public function getRefresh(): int 48 | { 49 | return $this->refresh; 50 | } 51 | 52 | public function getRetry(): int 53 | { 54 | return $this->retry; 55 | } 56 | 57 | public function getExpire(): int 58 | { 59 | return $this->expire; 60 | } 61 | 62 | public function getMinTTL(): int 63 | { 64 | return $this->minTTL; 65 | } 66 | 67 | 68 | public function toArray(): array 69 | { 70 | return [ 71 | 'mname' => (string)$this->mname, 72 | 'rname' => (string)$this->rname, 73 | 'serial' => $this->serial, 74 | 'refresh' => $this->refresh, 75 | 'retry' => $this->retry, 76 | 'expire' => $this->expire, 77 | 'minimumTTL' => $this->minTTL, 78 | ]; 79 | } 80 | 81 | public function __unserialize(array $unserialized): void 82 | { 83 | $this->mname = new Hostname($unserialized['mname']); 84 | $this->rname = new Hostname($unserialized['rname']); 85 | $this->serial = $unserialized['serial']; 86 | $this->refresh = $unserialized['refresh']; 87 | $this->retry = $unserialized['retry']; 88 | $this->expire = $unserialized['expire']; 89 | $this->minTTL = $unserialized['minimumTTL']; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /tests/Integration/BaseTestAbstract.php: -------------------------------------------------------------------------------- 1 | createLocalSystemResolver()); 66 | } 67 | 68 | protected function createStdIOSubscriber(): EventSubscriberInterface 69 | { 70 | return new STDIOSubscriber(new SplFileObject('php://stdout'), new SplFileObject('php://stderr')); 71 | } 72 | 73 | protected function attachTestSubscribers(ResolverAbstract $resolver): void 74 | { 75 | $subscribers = [$this->createStdIOSubscriber()]; 76 | 77 | foreach ($subscribers as $subscriber) { 78 | $resolver->addSubscriber($subscriber); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/Unit/Mappers/GoogleDNSTest.php: -------------------------------------------------------------------------------- 1 | 1, 'TTL' => 343, 'data' => '127.0.0.1', 'name' => 'yelp.com.', 16 | ], 17 | [ 18 | 'type' => 5, 'TTL' => 343, 'name' => 'google.com.', 19 | ], 20 | [ 21 | 'type' => 38, 'TTL' => 343, 'name' => 'google.com.', 22 | ], 23 | ]; 24 | 25 | protected function setUp(): void 26 | { 27 | parent::setUp(); 28 | 29 | $this->mapper = new GoogleDNS(); 30 | } 31 | 32 | /** 33 | * @test 34 | */ 35 | public function mapsAGoogleDNSRecordVariants(): void 36 | { 37 | $mappedRecord = $this->mapper->mapFields([ 38 | 'type' => 1, 39 | 'TTL' => 365, 40 | 'data' => '127.0.0.1', 41 | 'name' => 'facebook.com.', 42 | ])->toDNSRecord(); 43 | 44 | 45 | $this->assertEquals( 46 | DNSRecord::createFromPrimitives('A', 'facebook.com', 365, '127.0.0.1'), 47 | $mappedRecord 48 | ); 49 | 50 | $mappedRecord = $this->mapper->mapFields([ 51 | 'type' => 16, 52 | 'TTL' => 365, 53 | 'name' => 'facebook.com.', 54 | 'data' => '"txtval"', 55 | ])->toDNSRecord(); 56 | 57 | $this->assertEquals( 58 | DNSRecord::createFromPrimitives('TXT', 'facebook.com', 365, null, 'IN', 'txtval'), 59 | $mappedRecord 60 | ); 61 | 62 | $mappedRecord = $this->mapper->mapFields([ 63 | 'type' => 12, 64 | 'TTL' => 18930, 65 | 'name' => '8.8.8.8.in-addr.arpa.', 66 | 'data' => 'dns.google.' 67 | ])->toDNSRecord(); 68 | 69 | $this->assertEquals( 70 | DNSRecord::createFromPrimitives('PTR', '8.8.8.8.in-addr.arpa.', 18930, null, 'IN', 'dns.google.'), 71 | $mappedRecord 72 | ); 73 | } 74 | 75 | /** 76 | * @test 77 | */ 78 | public function mapsAllKindsOfGoogleDNSRecordVariants(): void 79 | { 80 | foreach (self::GOOGLE_DNS_FORMAT as $record) { 81 | $mappedRecord = $this->mapper->mapFields($record)->toDNSRecord(); 82 | $this->assertInstanceOf(DNSRecord::class, $mappedRecord); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/Unit/Entities/HostnameTest.php: -------------------------------------------------------------------------------- 1 | hostname = new Hostname('facebook.com'); 18 | } 19 | 20 | /** 21 | * @test 22 | */ 23 | public function hasBasicGettersAndIsStringy(): void 24 | { 25 | $this->assertSame('facebook.com.', (string)$this->hostname); 26 | $this->assertSame('facebook.com.', $this->hostname->getHostName()); 27 | $this->assertSame('facebook.com', $this->hostname->getHostnameWithoutTrailingDot()); 28 | } 29 | 30 | /** 31 | * @test 32 | */ 33 | public function testsForEquality(): void 34 | { 35 | $facebook1 = Hostname::createFromString('facebook.com'); 36 | $facebook2 = Hostname::createFromString('facebook.com'); 37 | $google = Hostname::createFromString('google.com'); 38 | 39 | $this->assertTrue($facebook1->equals($facebook2)); 40 | $this->assertFalse($facebook2->equals($google)); 41 | } 42 | 43 | /** 44 | * @test 45 | */ 46 | public function doesNotAllowInvalidHostNames(): void 47 | { 48 | $this->expectException(InvalidArgumentException::class); 49 | 50 | $hostname = implode('', array_fill(0, 64, 'A')); 51 | 52 | Hostname::createFromString($hostname); 53 | } 54 | 55 | /** 56 | * @test 57 | */ 58 | public function handlesIDNOperations(): void 59 | { 60 | $utf8IDN = 'ańodelgatos.com.'; 61 | $IDN = Hostname::createFromString($utf8IDN); 62 | 63 | $expectedAscii = 'xn--aodelgatos-w0b.com.'; 64 | $this->assertTrue($IDN->isPunycoded()); 65 | $this->assertSame($expectedAscii, $IDN->getHostName()); 66 | $this->assertSame($utf8IDN, $IDN->toUTF8()); 67 | } 68 | 69 | /** 70 | * @test 71 | * @dataProvider validHostnamesProvider 72 | */ 73 | public function createsHostnamesFromString(string $hostname): void 74 | { 75 | $this->assertInstanceOf(Hostname::class, Hostname::createFromString($hostname)); 76 | } 77 | 78 | public function validHostnamesProvider(): array 79 | { 80 | return [ 81 | ['google.com'], 82 | ['subdomain.google.com'], 83 | ['mandrill._domainkey.domain.com'], 84 | ['google.com.'], 85 | ]; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Resolvers/Dig.php: -------------------------------------------------------------------------------- 1 | dig = $dig ?? new Dns(); 45 | 46 | if ($nameserver !== null) { 47 | $this->dig = $this->dig->useNameserver((string) $nameserver); 48 | } 49 | 50 | $this->mapper = $mapper ?? new DigMapper(); 51 | } 52 | 53 | protected function doQuery(Hostname $hostname, DNSRecordType $recordType): DNSRecordCollection 54 | { 55 | if (!self::isSupportedQueryType($recordType)) { 56 | return new DNSRecordCollection(); 57 | } 58 | 59 | try { 60 | $response = ($recordType->equals(DNSRecordType::createANY())) 61 | ? $this->dig->getRecords((string) $hostname, self::SUPPORTED_QUERY_TYPES) 62 | : $this->dig->getRecords((string) $hostname, (string) $recordType); 63 | } catch (Throwable $e) { 64 | throw new QueryFailure($e->getMessage(), 0, $e); 65 | } 66 | 67 | return $this->mapResults($this->mapper, array_map(fn(Record $record): array => $record->toArray(), $response)); 68 | } 69 | 70 | private static function isSupportedQueryType(DNSRecordType $type): bool 71 | { 72 | if ($type->isA(DNSRecordType::TYPE_ANY)) { 73 | return true; 74 | } 75 | 76 | foreach (self::SUPPORTED_QUERY_TYPES as $queryType) { 77 | if ($type->isA($queryType)) { 78 | return true; 79 | } 80 | } 81 | 82 | return false; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Unit/Observability/Events/DNSQueryFailedTest.php: -------------------------------------------------------------------------------- 1 | resolver = $this->createMock(Resolver::class); 31 | $this->resolver->method('getName') 32 | ->willReturn('foo'); 33 | 34 | $this->hostname = Hostname::createFromString('facebook.com'); 35 | $this->recordType = DNSRecordType::createTXT(); 36 | $this->error = new Exception(); 37 | 38 | $this->DNSQueryFailed = new DNSQueryFailed( 39 | $this->resolver, 40 | $this->hostname, 41 | $this->recordType, 42 | $this->error 43 | ); 44 | } 45 | 46 | /** 47 | * @test 48 | */ 49 | public function hasBasicGetters(): void 50 | { 51 | $this->assertSame($this->resolver, $this->DNSQueryFailed->getResolver()); 52 | $this->assertSame($this->hostname, $this->DNSQueryFailed->getHostname()); 53 | $this->assertSame($this->recordType, $this->DNSQueryFailed->getRecordType()); 54 | $this->assertSame($this->error, $this->DNSQueryFailed->getError()); 55 | $this->assertSame(DNSQueryFailed::NAME, $this->DNSQueryFailed::getName()); 56 | } 57 | 58 | /** 59 | * @test 60 | */ 61 | public function isArrayable(): void 62 | { 63 | $expected = [ 64 | 'resolver' => 'foo', 65 | 'hostname' => (string)$this->hostname, 66 | 'type' => (string)$this->recordType, 67 | 'error' => $this->error, 68 | ]; 69 | 70 | $this->assertInstanceOf(Arrayable::class, $this->DNSQueryFailed); 71 | $this->assertInstanceOf(JsonSerializable::class, $this->error); 72 | $this->assertEquals($expected, $this->DNSQueryFailed->toArray()); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Mappers/Dig.php: -------------------------------------------------------------------------------- 1 | fields['type'], 23 | $this->fields['host'], 24 | $this->fields['ttl'], 25 | $this->fields['ip'] ?? $this->fields['ipv6'] ?? null, 26 | $this->fields['class'], 27 | ); 28 | 29 | return match ((string) $this->fields['type']) { 30 | 'A', 'AAAA' => $baseRecord, 31 | 'CAA' => $baseRecord 32 | ->setData(new CAAData($this->fields['flags'], $this->fields['tag'], $this->fields['value'])), 33 | 'CNAME' => $baseRecord 34 | ->setData(new CNAMEData(Hostname::createFromString($this->fields['target']))), 35 | 'MX' => $baseRecord 36 | ->setData(new MXData(Hostname::createFromString($this->fields['target']), $this->fields['pri'])), 37 | 'NS' => $baseRecord 38 | ->setData(new NSData(Hostname::createFromString($this->fields['target']))), 39 | 'SOA' => $baseRecord 40 | ->setData(new SOAData( 41 | Hostname::createFromString($this->fields['mname']), 42 | Hostname::createFromString($this->fields['rname']), 43 | $this->fields['serial'], 44 | $this->fields['refresh'], 45 | $this->fields['retry'], 46 | $this->fields['expire'], 47 | $this->fields['minimum_ttl'], 48 | )), 49 | 'SRV' => $baseRecord 50 | ->setData(new SRVData( 51 | $this->fields['pri'], 52 | $this->fields['weight'], 53 | $this->fields['port'], 54 | Hostname::createFromString($this->fields['target']) 55 | )), 56 | 'TXT' => $baseRecord 57 | ->setData(new TXTData($this->fields['txt'])), 58 | default => throw new InvalidArgumentException($this->fields['type'] . ' is not supported by dig') 59 | }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Unit/Entities/SRVDataTest.php: -------------------------------------------------------------------------------- 1 | SRVData = new SRVData($this->priority, $this->weight, $this->port, new Hostname($this->target)); 28 | } 29 | 30 | /** 31 | * @test 32 | */ 33 | public function knowsIfEquals(): void 34 | { 35 | $this->assertTrue($this->SRVData->equals($this->SRVData)); 36 | $this->assertFalse($this->SRVData->equals(new SRVData(1, 2, 3, new Hostname('thing.co.')))); 37 | } 38 | 39 | /** 40 | * @test 41 | */ 42 | public function isArrayable(): void 43 | { 44 | $this->assertArrayableAndEquals( 45 | [ 46 | 'priority' => $this->priority, 47 | 'weight' => $this->weight, 48 | 'port' => $this->port, 49 | 'target' => $this->target, 50 | ], 51 | $this->SRVData 52 | ); 53 | } 54 | 55 | /** 56 | * @test 57 | */ 58 | public function isJsonSerializeable(): void 59 | { 60 | $this->assertJsonSerializeableAndEquals( 61 | [ 62 | 'priority' => $this->priority, 63 | 'weight' => $this->weight, 64 | 'port' => $this->port, 65 | 'target' => $this->target, 66 | ], 67 | $this->SRVData 68 | ); 69 | } 70 | 71 | /** 72 | * @test 73 | */ 74 | public function isSerializable(): void 75 | { 76 | $this->assertSerializable($this->SRVData); 77 | $this->assertEquals($this->SRVData, unserialize(serialize($this->SRVData))); 78 | } 79 | 80 | /** 81 | * @test 82 | */ 83 | public function isStringable(): void 84 | { 85 | $this->assertStringableAndEquals('100 200 9090 target.co.', $this->SRVData); 86 | } 87 | 88 | /** 89 | * @test 90 | */ 91 | public function hasBasicGetters(): void 92 | { 93 | $this->assertSame($this->priority, $this->SRVData->getPriority()); 94 | $this->assertSame($this->weight, $this->SRVData->getWeight()); 95 | $this->assertSame($this->port, $this->SRVData->getPort()); 96 | $this->assertSame($this->target, $this->SRVData->getTarget()->__toString()); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/Unit/Entities/CAADataTest.php: -------------------------------------------------------------------------------- 1 | CAAData = new CAAData($this->flags, $this->tag, $this->value); 27 | } 28 | 29 | /** 30 | * @test 31 | */ 32 | public function knowsIfEquals(): void 33 | { 34 | $this->assertTrue($this->CAAData->equals($this->CAAData)); 35 | $this->assertFalse($this->CAAData->equals(new CAAData(0, 'issue', 'boop.com'))); 36 | } 37 | 38 | /** 39 | * @test 40 | */ 41 | public function isArrayable(): void 42 | { 43 | $this->assertArrayableAndEquals( 44 | ['flags' => $this->flags, 'tag' => $this->tag, 'value' => ';'], 45 | $this->CAAData 46 | ); 47 | } 48 | 49 | /** 50 | * @test 51 | */ 52 | public function isJsonSerializable(): void 53 | { 54 | $this->assertJsonSerializeableAndEquals( 55 | ['flags' => $this->flags, 'tag' => $this->tag, 'value' => ';'], 56 | $this->CAAData 57 | ); 58 | } 59 | 60 | /** 61 | * @test 62 | */ 63 | public function isSerializable(): void 64 | { 65 | $this->assertSerializable($this->CAAData); 66 | $this->assertEquals($this->CAAData, unserialize(serialize($this->CAAData))); 67 | } 68 | 69 | /** 70 | * @test 71 | */ 72 | public function isStringable(): void 73 | { 74 | $this->assertStringableAndEquals('0 issue ";"', $this->CAAData); 75 | } 76 | 77 | /** 78 | * @test 79 | */ 80 | public function hasBasicGetters(): void 81 | { 82 | $this->assertSame($this->flags, $this->CAAData->getFlags()); 83 | $this->assertSame($this->tag, $this->CAAData->getTag()); 84 | $this->assertSame(';', $this->CAAData->getValue()); 85 | 86 | $nullDefault = new CAAData(0, 'issue'); 87 | $this->assertNull($nullDefault->getValue()); 88 | } 89 | 90 | /** 91 | * @test 92 | */ 93 | public function doesNotAllowSpaceCharactersAsValidValue(): void 94 | { 95 | $this->expectException(Exceptions\InvalidArgumentException::class); 96 | $badValue = '\'\\# 26 00 09 69 73 73 75 65 77 69 6c 64 6c 65 74 73 65 6e 63 72 79 70 74 2e 6f 72 67\''; 97 | new CAAData(0, 'issuewild', $badValue); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/Unit/Resolvers/DigTest.php: -------------------------------------------------------------------------------- 1 | dig = $this->createPartialMock(Dns::class, ['getRecords']); 31 | $this->mapper = new DigMapper(); 32 | $this->nameserver = Hostname::createFromString('example.com'); 33 | $this->hostname = Hostname::createFromString('facebook.com'); 34 | $this->resolver = new Dig($this->dig, $this->mapper, $this->nameserver); 35 | $this->record = A::make([ 36 | 'host' => 'facebook.com', 37 | 'ttl' => 123, 38 | 'class' => 'IN', 39 | 'type' => 'A', 40 | 'ip' => '192.168.1.1' 41 | ]); 42 | 43 | $this->assertSame('example.com.', $this->dig->getNameserver()); 44 | } 45 | 46 | public function testQuerysSupportedRecord(): void 47 | { 48 | 49 | $this->dig->method('getRecords') 50 | ->with('facebook.com.', 'A') 51 | ->willReturn([$this->record]); 52 | 53 | $expectedRecord = new DNSRecord( 54 | DNSRecordType::createA(), 55 | $this->hostname, 56 | 123, 57 | IPAddress::createFromString('192.168.1.1') 58 | ); 59 | 60 | $actual = $this->resolver->getRecords((string) $this->hostname, DNSRecordType::TYPE_A)[0]; 61 | $this->assertTrue($expectedRecord->equals($actual)); 62 | } 63 | 64 | public function testReturnsEmptyOnUnsupportedRecord(): void 65 | { 66 | 67 | $this->dig->expects($this->never()) 68 | ->method('getRecords'); 69 | 70 | $this->assertTrue($this->resolver->getRecords((string) $this->hostname, DNSRecordType::TYPE_PTR)->isEmpty()); 71 | } 72 | 73 | public function testRecastsASpatieFailureAsQueryFailure(): void 74 | { 75 | 76 | $this->dig->method('getRecords') 77 | ->with('facebook.com.', 'A') 78 | ->willThrowException(new \DomainException('yo')); 79 | 80 | $this->expectException(QueryFailure::class); 81 | $this->expectExceptionMessage('yo'); 82 | 83 | $this->resolver->getRecords((string) $this->hostname, DNSRecordType::TYPE_A); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/Unit/Observability/Traits/DispatcherTest.php: -------------------------------------------------------------------------------- 1 | dispatcher = $this->createMock(EventDispatcherInterface::class); 27 | $this->subscriber = $this->createMock(EventSubscriberInterface::class); 28 | $this->event = new class extends ObservableEventAbstract 29 | { 30 | public static function getName(): string 31 | { 32 | return 'the name'; 33 | } 34 | 35 | public function toArray(): array 36 | { 37 | return ['beep' => 'boop']; 38 | } 39 | }; 40 | 41 | $this->observableClass = new class implements Observable { 42 | use Dispatcher; 43 | }; 44 | } 45 | 46 | /** 47 | * @test 48 | */ 49 | public function createsAndCachesDispatcher(): void 50 | { 51 | $this->dispatcher->expects($this->never()) 52 | ->method('dispatch'); 53 | 54 | $this->observableClass->dispatch($this->event); 55 | } 56 | 57 | /** 58 | * @test 59 | */ 60 | public function addsSubscribersAndListeners(): void 61 | { 62 | $this->observableClass->setDispatcher($this->dispatcher); 63 | 64 | $this->dispatcher->expects($this->once()) 65 | ->method('addSubscriber') 66 | ->with($this->subscriber); 67 | 68 | $this->dispatcher->expects($this->once()) 69 | ->method('addListener') 70 | ->with('boop', function () { 71 | }); 72 | 73 | $this->observableClass->addSubscriber($this->subscriber); 74 | $this->observableClass->addListener('boop', function () { 75 | }); 76 | } 77 | 78 | /** 79 | * @test 80 | */ 81 | public function dispatchesObservableEvents(): void 82 | { 83 | $this->observableClass->setDispatcher($this->dispatcher); 84 | 85 | $this->dispatcher->expects($this->once()) 86 | ->method('dispatch') 87 | ->with($this->event, $this->event::getName()); 88 | 89 | $this->observableClass->dispatch($this->event); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Entities/DataAbstract.php: -------------------------------------------------------------------------------- 1 | toArray(); 22 | } 23 | 24 | public function equals(DataAbstract $dataAbstract): bool 25 | { 26 | return (string)$this === (string)$dataAbstract; 27 | } 28 | 29 | /** 30 | * @throws \RemotelyLiving\PHPDNS\Exceptions\InvalidArgumentException 31 | */ 32 | public static function createFromTypeAndString(DNSRecordType $recordType, string $data): self 33 | { 34 | if ($recordType->isA(DNSRecordType::TYPE_TXT)) { 35 | return new TXTData(trim($data, '"')); 36 | } 37 | 38 | if ($recordType->isA(DNSRecordType::TYPE_NS)) { 39 | return new NSData(new Hostname($data)); 40 | } 41 | 42 | if ($recordType->isA(DNSRecordType::TYPE_CNAME)) { 43 | return new CNAMEData(new Hostname($data)); 44 | } 45 | 46 | $parsed = self::parseDataToArray($data); 47 | 48 | if ($recordType->isA(DNSRecordType::TYPE_MX)) { 49 | return new MXData(new Hostname($parsed[1]), (int)$parsed[0]); 50 | } 51 | 52 | if ($recordType->isA(DNSRecordType::TYPE_SOA)) { 53 | return new SOAData( 54 | new Hostname($parsed[0]), 55 | new Hostname($parsed[1]), 56 | (int)($parsed[2] ?? 0), 57 | (int)($parsed[3] ?? 0), 58 | (int)($parsed[4] ?? 0), 59 | (int)($parsed[5] ?? 0), 60 | (int)($parsed[6] ?? 0) 61 | ); 62 | } 63 | 64 | if ($recordType->isA(DNSRecordType::TYPE_CAA) && count($parsed) === 3) { 65 | return new CAAData((int)$parsed[0], (string)$parsed[1], $parsed[2]); 66 | } 67 | 68 | if ($recordType->isA(DNSRecordType::TYPE_SRV)) { 69 | return new SRVData( 70 | (int)$parsed[0] ?: 0, 71 | (int) $parsed[1] ?: 0, 72 | (int) $parsed[2] ?: 0, 73 | new Hostname($parsed[3]) 74 | ); 75 | } 76 | 77 | if ($recordType->isA(DNSRecordType::TYPE_PTR)) { 78 | return new PTRData(new Hostname($data)); 79 | } 80 | 81 | throw new InvalidArgumentException("{$data} could not be created with type {$recordType}"); 82 | } 83 | 84 | public function jsonSerialize(): array 85 | { 86 | return $this->toArray(); 87 | } 88 | 89 | protected function init(): void 90 | { 91 | } 92 | private static function parseDataToArray(string $data): array 93 | { 94 | return explode(' ', $data); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/Unit/Observability/Events/DNSQueriedTest.php: -------------------------------------------------------------------------------- 1 | resolver = $this->createMock(Resolver::class); 32 | $this->resolver->method('getName') 33 | ->willReturn('foo'); 34 | 35 | $this->hostname = Hostname::createFromString('facebook.com'); 36 | $this->recordType = DNSRecordType::createTXT(); 37 | $this->recordCollection = new DNSRecordCollection(); 38 | 39 | $this->DNSQueried = new DNSQueried( 40 | $this->resolver, 41 | $this->hostname, 42 | $this->recordType, 43 | $this->recordCollection 44 | ); 45 | } 46 | 47 | /** 48 | * @test 49 | */ 50 | public function hasBasicGetters(): void 51 | { 52 | $this->assertSame($this->resolver, $this->DNSQueried->getResolver()); 53 | $this->assertSame($this->hostname, $this->DNSQueried->getHostname()); 54 | $this->assertSame($this->recordType, $this->DNSQueried->getRecordType()); 55 | $this->assertSame($this->recordCollection, $this->DNSQueried->getRecordCollection()); 56 | $this->assertSame(DNSQueried::NAME, $this->DNSQueried::getName()); 57 | } 58 | 59 | /** 60 | * @test 61 | */ 62 | public function isArrayable(): void 63 | { 64 | $record1 = $this->createMock(DNSRecordInterface::class); 65 | $record1->method('toArray') 66 | ->willReturn(['herp' => 'derp']); 67 | 68 | $record2 = $this->createMock(DNSRecordInterface::class); 69 | $record2->method('toArray') 70 | ->willReturn(['beep' => 'boop']); 71 | 72 | $expected = [ 73 | 'resolver' => 'foo', 74 | 'hostname' => (string)$this->hostname, 75 | 'type' => (string)$this->recordType, 76 | 'records' => [ 77 | ['herp' => 'derp'], 78 | ['beep' => 'boop'], 79 | ], 80 | 'empty' => false, 81 | ]; 82 | 83 | $this->recordCollection[] = $record1; 84 | $this->recordCollection[] = $record2; 85 | 86 | $this->assertInstanceOf(Arrayable::class, $this->DNSQueried); 87 | $this->assertEquals($expected, $this->DNSQueried->toArray()); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Resolvers/GoogleDNS.php: -------------------------------------------------------------------------------- 1 | self::BASE_URI, 26 | 'strict' => true, 27 | 'allow_redirects' => false, 28 | 'connect_timeout' => self::DEFAULT_TIMEOUT, 29 | 'protocols' => ['https'], 30 | 'headers' => [ 31 | 'Accept' => 'application/json', 32 | ], 33 | ]; 34 | 35 | private ClientInterface $http; 36 | 37 | private GoogleDNSMapper $mapper; 38 | 39 | /** 40 | * @param array $options 41 | */ 42 | public function __construct( 43 | ClientInterface $http = null, 44 | GoogleDNSMapper $mapper = null, 45 | private int $consensusAttempts = 3, 46 | private array $options = self::DEFAULT_OPTIONS 47 | ) { 48 | $this->http = $http ?? new Client(); 49 | $this->mapper = $mapper ?? new GoogleDNSMapper(); 50 | } 51 | 52 | /** 53 | * Google DNS has consistency issues so this tries a few times to get an answer 54 | */ 55 | public function hasRecord(DNSRecordInterface $record): bool 56 | { 57 | $attempts = 0; 58 | 59 | do { 60 | $hasRecord = $this->getRecords((string)$record->getHostname(), (string)$record->getType()) 61 | ->has($record); 62 | 63 | ++$attempts; 64 | } while (!$hasRecord && $attempts < $this->consensusAttempts); 65 | 66 | return $hasRecord; 67 | } 68 | 69 | protected function doQuery(Hostname $hostname, DNSRecordType $recordType): DNSRecordCollection 70 | { 71 | $results = $this->doApiQuery(['name' => (string)$hostname, 'type' => (string)$recordType]); 72 | 73 | return $this->mapResults($this->mapper, $results); 74 | } 75 | 76 | /** 77 | * @throws \RemotelyLiving\PHPDNS\Resolvers\Exceptions\QueryFailure 78 | */ 79 | private function doApiQuery(array $query = []): array 80 | { 81 | try { 82 | $response = $this->http->request('GET', '/resolve?' . http_build_query($query), $this->options); 83 | } catch (Throwable $e) { 84 | throw new QueryFailure("Unable to query GoogleDNS API", 0, $e); 85 | } 86 | 87 | $result = (array) json_decode((string)$response->getBody(), true); 88 | 89 | if (isset($result['Answer'])) { 90 | return $result['Answer']; 91 | } 92 | 93 | if (isset($result['Authority'])) { 94 | return $result['Authority']; 95 | } 96 | 97 | return []; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/Unit/Observability/Subscribers/STDIOSubscriberTest.php: -------------------------------------------------------------------------------- 1 | STDOut = $this->getMockBuilder(SplFileObject::class) 30 | ->setConstructorArgs(['php://memory']) 31 | ->setMethods(['fwrite']) 32 | ->getMock(); 33 | 34 | $this->STDErr = $this->getMockBuilder(SplFileObject::class) 35 | ->setConstructorArgs(['php://memory']) 36 | ->setMethods(['fwrite']) 37 | ->getMock(); 38 | 39 | $this->event = new class extends ObservableEventAbstract 40 | { 41 | public static function getName(): string 42 | { 43 | return 'the name'; 44 | } 45 | 46 | public function toArray(): array 47 | { 48 | return ['beep' => 'boop']; 49 | } 50 | }; 51 | 52 | $this->expectedOut = <<subscriber = new STDIOSubscriber($this->STDOut, $this->STDErr); 61 | } 62 | 63 | /** 64 | * @test 65 | */ 66 | public function getsSubscribedEvents(): void 67 | { 68 | $this->assertEquals([ 69 | DNSQueryFailed::getName() => 'onDNSQueryFailed', 70 | DNSQueried::getName() => 'onDNSQueried', 71 | DNSQueryProfiled::getName() => 'onDNSQueryProfiled', 72 | ], $this->subscriber::getSubscribedEvents()); 73 | } 74 | 75 | /** 76 | * @test 77 | */ 78 | public function writesToSTDErrOnQueryFailure(): void 79 | { 80 | 81 | 82 | $this->STDErr->expects($this->once()) 83 | ->method('fwrite') 84 | ->with($this->expectedOut); 85 | 86 | $this->subscriber->onDNSQueryFailed($this->event); 87 | } 88 | 89 | /** 90 | * @test 91 | */ 92 | public function writesToSTDOutOnQuery(): void 93 | { 94 | $this->STDOut->expects($this->once()) 95 | ->method('fwrite') 96 | ->with($this->expectedOut); 97 | 98 | $this->subscriber->onDNSQueried($this->event); 99 | } 100 | 101 | /** 102 | * @test 103 | */ 104 | public function writesToSTDOutOnQueryProfiled(): void 105 | { 106 | $this->STDOut->expects($this->once()) 107 | ->method('fwrite') 108 | ->with($this->expectedOut); 109 | 110 | $this->subscriber->onDNSQueryProfiled($this->event); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/Unit/Mappers/DigTest.php: -------------------------------------------------------------------------------- 1 | mapper = new Dig(); 18 | } 19 | 20 | /** 21 | * @test 22 | */ 23 | public function willNotMapUnsupportedType(): void 24 | { 25 | $this->expectException(InvalidArgumentException::class); 26 | $this->mapper->mapFields(['type' => 'PTR', 'host' => 'google.com', 'ttl' => 1, 'class' => 'IN'])->toDNSRecord(); 27 | } 28 | 29 | /** 30 | * @dataProvider recordProvider 31 | * @test 32 | */ 33 | public function mapsASpatieDigRecordToDNSRecord(array $fields): void 34 | { 35 | $type = DNSRecordType::createFromString($fields['type']); 36 | 37 | $this->assertTrue($type->equals($this->mapper->mapFields($fields)->toDNSRecord()->getType())); 38 | } 39 | 40 | public function recordProvider(): array 41 | { 42 | return [ 43 | 'A' => [['type' => 'A', 'host' => 'google.com', 'ttl' => 123, 'ip' => '192.168.1.1', 'class' => 'IN']], 44 | 'AAAA' => [[ 45 | 'type' => 'AAAA', 46 | 'host' => 'google.com', 47 | 'ttl' => 123, 48 | 'ip' => '2001:db8:3333:4444:5555:6666:7777:8888', 49 | 'class' => 'IN' 50 | ]], 51 | 'CAA' => [[ 52 | 'type' => 'CAA', 53 | 'host' => 'google.com', 54 | 'ttl' => 123, 55 | 'flags' => 3, 56 | 'class' => 'IN', 57 | 'tag' => 'yo', 58 | 'value' => 'gabbagabba', 59 | ]], 60 | 'CNAME' => [[ 61 | 'type' => 'CNAME', 62 | 'host' => 'google.com', 63 | 'class' => 'IN', 64 | 'ttl' => 123, 65 | 'target' => 'www.google.com', 66 | ]], 67 | 'MX' => [[ 68 | 'type' => 'MX', 69 | 'host' => 'google.com', 70 | 'ttl' => 123, 71 | 'class' => 'IN', 72 | 'target' => 'www.google.com', 73 | 'pri' => 100, 74 | ]], 75 | 'NS' => [[ 76 | 'type' => 'NS', 77 | 'host' => 'google.com', 78 | 'ttl' => 123, 79 | 'class' => 'IN', 80 | 'target' => 'www.namecheap.com', 81 | ]], 82 | 'SOA' => [[ 83 | 'type' => 'SOA', 84 | 'host' => 'google.com', 85 | 'ttl' => 123, 86 | 'class' => 'IN', 87 | 'mname' => 'www.google.com', 88 | 'rname' => 'google.com', 89 | 'serial' => 234234, 90 | 'refresh' => 1, 91 | 'retry' => 2, 92 | 'expire' => 3, 93 | 'minimum_ttl' => 4, 94 | ]], 95 | 'SRV' => [[ 96 | 'type' => 'SRV', 97 | 'host' => 'google.com', 98 | 'ttl' => 123, 99 | 'class' => 'IN', 100 | 'target' => 'www.google.com', 101 | 'pri' => 100, 102 | 'weight' => 4, 103 | 'port' => 8080, 104 | ]], 105 | 'TXT' => [[ 106 | 'type' => 'TXT', 107 | 'host' => 'google.com', 108 | 'ttl' => 123, 109 | 'class' => 'IN', 110 | 'txt' => 'to be or not to be', 111 | ]], 112 | ]; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/Unit/Entities/SOADataTest.php: -------------------------------------------------------------------------------- 1 | mname = new Hostname('google.com'); 35 | $this->rname = new Hostname('facebook.com'); 36 | $this->SOAData = new SOAData( 37 | $this->mname, 38 | $this->rname, 39 | $this->serial, 40 | $this->refresh, 41 | $this->retry, 42 | $this->expire, 43 | $this->minTTL 44 | ); 45 | } 46 | 47 | /** 48 | * @test 49 | */ 50 | public function knowsIfEquals(): void 51 | { 52 | $anotherSOA = new SOAData($this->rname, $this->mname, 1, 1, 1, 1, 1); 53 | $this->assertTrue($this->SOAData->equals($this->SOAData)); 54 | $this->assertFalse($this->SOAData->equals($anotherSOA)); 55 | } 56 | 57 | /** 58 | * @test 59 | */ 60 | public function isArrayable(): void 61 | { 62 | $this->assertArrayableAndEquals( 63 | [ 64 | 'rname' => (string)$this->rname, 65 | 'mname' => (string)$this->mname, 66 | 'serial' => $this->serial, 67 | 'refresh' => $this->refresh, 68 | 'retry' => $this->retry, 69 | 'expire' => $this->expire, 70 | 'minimumTTL' => $this->minTTL, 71 | ], 72 | $this->SOAData 73 | ); 74 | } 75 | 76 | /** 77 | * @test 78 | */ 79 | public function isJsonSerializeable(): void 80 | { 81 | $this->assertJsonSerializeableAndEquals( 82 | [ 83 | 'rname' => (string)$this->rname, 84 | 'mname' => (string)$this->mname, 85 | 'serial' => $this->serial, 86 | 'refresh' => $this->refresh, 87 | 'retry' => $this->retry, 88 | 'expire' => $this->expire, 89 | 'minimumTTL' => $this->minTTL, 90 | ], 91 | $this->SOAData 92 | ); 93 | } 94 | 95 | /** 96 | * @test 97 | */ 98 | public function isSerializable(): void 99 | { 100 | $this->assertSerializable($this->SOAData); 101 | $this->assertEquals($this->SOAData, unserialize(serialize($this->SOAData))); 102 | } 103 | 104 | /** 105 | * @test 106 | */ 107 | public function isStringable(): void 108 | { 109 | $this->assertStringableAndEquals('google.com. facebook.com. 2342 123 321 3434 60', $this->SOAData); 110 | } 111 | 112 | /** 113 | * @test 114 | */ 115 | public function hasBasicGetters(): void 116 | { 117 | $this->assertSame($this->mname, $this->SOAData->getMname()); 118 | $this->assertSame($this->rname, $this->SOAData->getRname()); 119 | $this->assertSame($this->serial, $this->SOAData->getSerial()); 120 | $this->assertSame($this->refresh, $this->SOAData->getRefresh()); 121 | $this->assertSame($this->retry, $this->SOAData->getRetry()); 122 | $this->assertSame($this->expire, $this->SOAData->getExpire()); 123 | $this->assertSame($this->minTTL, $this->SOAData->getMinTTL()); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Resolvers/Cached.php: -------------------------------------------------------------------------------- 1 | cache->clear(); 42 | } 43 | 44 | public function withEmptyResultCachingDisabled(): self 45 | { 46 | $emptyCachingDisabled = new self($this->cache, $this->resolver, $this->ttlSeconds); 47 | $emptyCachingDisabled->shouldCacheEmptyResults = false; 48 | 49 | return $emptyCachingDisabled; 50 | } 51 | 52 | protected function doQuery(Hostname $hostname, DNSRecordType $recordType): DNSRecordCollection 53 | { 54 | $cachedResult = $this->cache->getItem($this->buildCacheKey($hostname, $recordType)); 55 | 56 | if ($cachedResult->isHit()) { 57 | return $this->unwrapResults($cachedResult->get()); 58 | } 59 | 60 | $dnsRecords = $this->resolver->getRecords((string)$hostname, (string)$recordType); 61 | if ($dnsRecords->isEmpty() && !$this->shouldCacheEmptyResults) { 62 | return $dnsRecords; 63 | } 64 | 65 | $ttlSeconds = $this->ttlSeconds ?? $this->extractLowestTTL($dnsRecords); 66 | $cachedResult->expiresAfter($ttlSeconds); 67 | $cachedResult->set(['recordCollection' => $dnsRecords, 'timestamp' => $this->getTimeStamp()]); 68 | $this->cache->save($cachedResult); 69 | 70 | return $dnsRecords; 71 | } 72 | 73 | private function buildCacheKey(Hostname $hostname, DNSRecordType $recordType): string 74 | { 75 | return md5(sprintf(self::CACHE_KEY_TEMPLATE, self::NAMESPACE, (string)$hostname, (string)$recordType)); 76 | } 77 | 78 | private function extractLowestTTL(DNSRecordCollection $recordCollection): int 79 | { 80 | $ttls = []; 81 | 82 | /** @var \RemotelyLiving\PHPDNS\Entities\DNSRecord $record */ 83 | foreach ($recordCollection as $record) { 84 | /** @scrutinizer ignore-call */ 85 | if ($record->getTTL() <= 0) { 86 | continue; 87 | } 88 | 89 | $ttls[] = $record->getTTL(); 90 | } 91 | 92 | return count($ttls) ? min($ttls) : self::DEFAULT_CACHE_TTL; 93 | } 94 | 95 | /** 96 | * @param array $results ['recordCollection' => $recordCollection, 'timestamp' => $timeStamp] 97 | */ 98 | private function unwrapResults(array $results): DNSRecordCollection 99 | { 100 | /** @var DNSRecordCollection $records */ 101 | $records = $results['recordCollection']; 102 | /** 103 | * @var int $key 104 | * @var \RemotelyLiving\PHPDNS\Entities\DNSRecord $record 105 | */ 106 | foreach ($records as $key => $record) { 107 | $records[$key] = $record 108 | ->setTTL(max($record->getTTL() - ($this->getTimeStamp() - (int)$results['timestamp']), 0)); 109 | } 110 | 111 | return $records; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Mappers/LocalSystem.php: -------------------------------------------------------------------------------- 1 | DNSRecordType::TYPE_A, 35 | DNS_CNAME => DNSRecordType::TYPE_CNAME, 36 | DNS_HINFO => DNSRecordType::TYPE_HINFO, 37 | DNS_CAA => DNSRecordType::TYPE_CAA, 38 | DNS_MX => DNSRecordType::TYPE_MX, 39 | DNS_NS => DNSRecordType::TYPE_NS, 40 | DNS_PTR => DNSRecordType::TYPE_PTR, 41 | DNS_SOA => DNSRecordType::TYPE_SOA, 42 | DNS_TXT => DNSRecordType::TYPE_TXT, 43 | DNS_AAAA => DNSRecordType::TYPE_AAAA, 44 | DNS_SRV => DNSRecordType::TYPE_SRV, 45 | DNS_NAPTR => DNSRecordType::TYPE_NAPTR, 46 | DNS_A6 => DNSRecordType::TYPE_A6, 47 | DNS_ANY => DNSRecordType::TYPE_ANY, 48 | ]; 49 | /** 50 | * @var string 51 | */ 52 | private const TARGET = 'target'; 53 | /** 54 | * @var string 55 | */ 56 | private const PRI = 'pri'; 57 | /** 58 | * @var string 59 | */ 60 | private const TEMPLATE = '%s %s %s %s %s %s %s'; 61 | 62 | public function toDNSRecord(): DNSRecordInterface 63 | { 64 | $IPAddress = null; 65 | 66 | if (isset($this->fields['ipv6'])) { 67 | $IPAddress = $this->fields['ipv6']; 68 | } 69 | 70 | if (isset($this->fields['ip'])) { 71 | $IPAddress = $this->fields['ip']; 72 | } 73 | 74 | return DNSRecord::createFromPrimitives( 75 | $this->fields['type'], 76 | $this->fields['host'], 77 | $this->fields['ttl'], 78 | $IPAddress, 79 | $this->fields['class'], 80 | $this->formatData($this->fields) 81 | ); 82 | } 83 | 84 | public function getTypeCodeFromType(DNSRecordType $type): int 85 | { 86 | return array_flip(self::PHP_CODE_TYPE_MAP)[(string)$type] ?? DNS_ANY; 87 | } 88 | 89 | private function formatData(array $fields): ?string 90 | { 91 | if (isset($this->fields['flags'], $fields['tag'], $fields['value'])) { 92 | return "{$fields['flags']} {$fields['tag']} \"{$fields['value']}\""; 93 | } 94 | 95 | if (isset($fields['mname'])) { 96 | return sprintf( 97 | self::TEMPLATE, 98 | $fields['mname'], 99 | $fields['rname'], 100 | $fields['serial'], 101 | $fields['refresh'], 102 | $fields['retry'], 103 | $fields['expire'], 104 | $fields['minimum-ttl'] 105 | ); 106 | } 107 | 108 | if (isset($fields[self::TARGET], $fields[self::PRI], $fields['weight'], $fields['port'])) { 109 | return "{$fields[self::PRI]} {$fields['weight']} {$fields['port']} {$fields[self::TARGET]}"; 110 | } 111 | 112 | 113 | if (isset($fields[self::TARGET], $fields[self::PRI])) { 114 | return "{$fields[self::PRI]} {$fields[self::TARGET]}"; 115 | } 116 | 117 | if (isset($fields[self::TARGET])) { 118 | return $fields[self::TARGET]; 119 | } 120 | 121 | if (isset($fields['txt'])) { 122 | return $fields['txt']; 123 | } 124 | 125 | return null; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Entities/DNSRecord.php: -------------------------------------------------------------------------------- 1 | recordType; 54 | } 55 | 56 | public function getHostname(): Hostname 57 | { 58 | return $this->hostname; 59 | } 60 | 61 | public function getTTL(): int 62 | { 63 | return $this->TTL; 64 | } 65 | 66 | public function getIPAddress(): ?IPAddress 67 | { 68 | return $this->IPAddress; 69 | } 70 | 71 | public function getClass(): string 72 | { 73 | return $this->class; 74 | } 75 | 76 | public function getData(): ?DataAbstract 77 | { 78 | return $this->data; 79 | } 80 | 81 | public function setData(DataAbstract $data): self 82 | { 83 | 84 | $this->data = $data; 85 | return $this; 86 | } 87 | 88 | public function setTTL(int $ttl): DNSRecordInterface 89 | { 90 | $this->TTL = $ttl; 91 | return $this; 92 | } 93 | 94 | public function toArray(): array 95 | { 96 | $formatted = [ 97 | 'hostname' => (string)$this->hostname, 98 | 'type' => (string)$this->recordType, 99 | 'TTL' => $this->TTL, 100 | 'class' => $this->class, 101 | ]; 102 | 103 | if ($this->IPAddress !== null) { 104 | $formatted['IPAddress'] = (string)$this->IPAddress; 105 | } 106 | 107 | if ($this->data !== null) { 108 | $formatted[self::DATA] = (string)$this->data; 109 | } 110 | 111 | return $formatted; 112 | } 113 | 114 | public function equals(DNSRecordInterface $record): bool 115 | { 116 | return $this->hostname->equals($record->getHostname()) 117 | && $this->recordType->equals($record->getType()) 118 | && (string)$this->data === (string)$record->getData() // could be null 119 | && (string)$this->IPAddress === (string)$record->getIPAddress(); // could be null 120 | } 121 | 122 | public function __serialize(): array 123 | { 124 | return $this->toArray(); 125 | } 126 | 127 | public function __unserialize(array $unserialized): void 128 | { 129 | $rawIPAddres = $unserialized['IPAddress'] ?? null; 130 | $this->recordType = DNSRecordType::createFromString($unserialized['type']); 131 | $this->hostname = Hostname::createFromString($unserialized['hostname']); 132 | $this->TTL = (int) $unserialized['TTL']; 133 | $this->IPAddress = $rawIPAddres ? IPAddress::createFromString($rawIPAddres) : null; 134 | $this->class = $unserialized['class']; 135 | $this->data = (isset($unserialized[self::DATA])) 136 | ? DataAbstract::createFromTypeAndString($this->recordType, $unserialized[self::DATA]) 137 | : null; 138 | } 139 | 140 | public function jsonSerialize(): array 141 | { 142 | return $this->toArray(); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Resolvers/CloudFlare.php: -------------------------------------------------------------------------------- 1 | self::BASE_URI, 27 | 'connect_timeout' => self::DEFAULT_TIMEOUT, 28 | 'strict' => true, 29 | 'allow_redirects' => false, 30 | 'protocols' => ['https'], 31 | 'headers' => [ 32 | 'Accept' => 'application/dns-json', 33 | ], 34 | ]; 35 | 36 | private ClientInterface $http; 37 | 38 | private CloudFlareMapper $mapper; 39 | 40 | /** 41 | * @param array $options 42 | */ 43 | public function __construct( 44 | ClientInterface $http = null, 45 | CloudFlareMapper $mapper = null, 46 | private array $options = self::DEFAULT_OPTIONS 47 | ) { 48 | $this->http = $http ?? new Client(); 49 | $this->mapper = $mapper ?? new CloudFlareMapper(); 50 | } 51 | 52 | protected function doQuery(Hostname $hostname, DNSRecordType $recordType): DNSRecordCollection 53 | { 54 | try { 55 | return ($recordType->isA(DNSRecordType::TYPE_ANY)) 56 | ? $this->doAnyApiQuery($hostname) 57 | : $this->doApiQuery($hostname, $recordType); 58 | } catch (Throwable $e) { 59 | throw new QueryFailure("Unable to query CloudFlare API", 0, $e); 60 | } 61 | } 62 | 63 | /** 64 | * Cloudflare does not support ANY queries, so we must ask for all record types individually 65 | */ 66 | private function doAnyApiQuery(Hostname $hostname): DNSRecordCollection 67 | { 68 | $results = []; 69 | $eachPromise = new EachPromise($this->generateEachTypeQuery($hostname), [ 70 | 'concurrency' => 4, 71 | 'fulfilled' => function (ResponseInterface $response) use (&$results) { 72 | $results = array_merge( 73 | $results, 74 | $this->parseResult((array) json_decode((string)$response->getBody(), true)) 75 | ); 76 | }, 77 | 'rejected' => function (Throwable $e): void { 78 | throw $e; 79 | }, 80 | ]); 81 | 82 | $eachPromise->promise()->wait(true); 83 | 84 | return $this->mapResults($this->mapper, $results); 85 | } 86 | 87 | private function generateEachTypeQuery(Hostname $hostname): Generator 88 | { 89 | foreach (DNSRecordType::VALID_TYPES as $type) { 90 | if ($type === DNSRecordType::TYPE_ANY) { 91 | continue 1; 92 | } 93 | 94 | yield $this->http->requestAsync( 95 | 'GET', 96 | '/dns-query?' . http_build_query(['name' => (string)$hostname, 'type' => $type]), 97 | $this->options 98 | ); 99 | } 100 | } 101 | 102 | private function doApiQuery(Hostname $hostname, DNSRecordType $type): DNSRecordCollection 103 | { 104 | $url = '/dns-query?' . http_build_query(['name' => (string)$hostname, 'type' => (string)$type]); 105 | $decoded = (array)json_decode( 106 | (string)$this->http->requestAsync('GET', $url, $this->options)->wait(true)->getBody(), 107 | true, 108 | 512, 109 | JSON_THROW_ON_ERROR 110 | ); 111 | 112 | return $this->mapResults($this->mapper, $this->parseResult($decoded)); 113 | } 114 | 115 | private function parseResult(array $result): array 116 | { 117 | if (isset($result['Answer'])) { 118 | return $result['Answer']; 119 | } 120 | 121 | if (isset($result['Authority'])) { 122 | return $result['Authority']; 123 | } 124 | 125 | return []; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tests/Unit/Mappers/LocalSystemTest.php: -------------------------------------------------------------------------------- 1 | 'AAAA', 17 | 'ttl' => 343, 18 | 'ipv6' => 'FE80:CD00:0000:0000:0000:0000:211E:729C', 19 | 'host' => 'yelp.com', 20 | 'class' => 'IN', 21 | ], 22 | [ 23 | 'type' => 'A', 24 | 'ttl' => 343, 25 | 'ip' => '127.0.0.1', 26 | 'host' => 'google.com', 27 | 'class' => 'IN', 28 | ], 29 | [ 30 | 'type' => 'CNAME', 31 | 'ttl' => 343, 32 | 'host' => 'google.com', 33 | 'class' => 'IN' 34 | ], 35 | [ 36 | 'type' => 'NS', 37 | 'ttl' => 343, 38 | 'host' => 'google.com', 39 | 'target' => 'ns.google.com.', 40 | 'class' => 'IN' 41 | ], 42 | [ 43 | 'type' => 'SOA', 44 | 'ttl' => 343, 45 | 'host' => 'google.com', 46 | 'mname' => 'ns.google.com.', 47 | 'rname' => 'dns.google.com.', 48 | 'serial' => 1234, 49 | 'refresh' => 60, 50 | 'retry' => 180, 51 | 'expire' => 320, 52 | 'minimum-ttl' => 84, 53 | 'class' => 'IN' 54 | ], 55 | [ 56 | 'type' => 'MX', 57 | 'ttl' => 343, 58 | 'host' => 'google.com', 59 | 'target' => 'ns.google.com.', 60 | 'pri' => 60, 61 | 'class' => 'IN', 62 | ], 63 | [ 64 | 'type' => 'TXT', 65 | 'ttl' => 343, 66 | 'host' => 'google.com', 67 | 'txt' => 'txtval', 68 | 'class' => 'IN', 69 | ], 70 | [ 71 | 'type' => 'CNAME', 72 | 'class' => 'IN', 73 | 'host' => 'www.google.com', 74 | 'ttl' => 234, 75 | 'target' => 'google.com', 76 | ], 77 | [ 78 | 'type' => 'CAA', 79 | 'host' => 'thing.com', 80 | 'class' => 'IN', 81 | 'value' => 'google.com', 82 | 'ttl' => 234, 83 | 'tag' => 'issue', 84 | 'flags' => 0 85 | ], 86 | [ 87 | 'type' => 'SRV', 88 | 'host' => '_x-puppet._tcp.dnscheck.co.', 89 | 'target' => 'master-a.dnscheck.co.', 90 | 'pri' => 100, 91 | 'ttl' => 833, 92 | 'class' => 'IN', 93 | 'weight' => 200, 94 | 'port' => 9999, 95 | ], 96 | [ 97 | 'host' => '8.8.8.8.in-addr.arpa', 98 | 'class' => 'IN', 99 | 'ttl' => 15248, 100 | 'type' => 'PTR', 101 | 'target' => 'dns.google' 102 | 103 | ] 104 | ]; 105 | 106 | protected function setUp(): void 107 | { 108 | parent::setUp(); 109 | 110 | $this->mapper = new LocalSystem(); 111 | } 112 | 113 | /** 114 | * @test 115 | */ 116 | public function mapsAGoogleDNSRecordVariants(): void 117 | { 118 | $mappedRecord = $this->mapper->mapFields([ 119 | 'type' => 'A', 120 | 'ttl' => 365, 121 | 'ip' => '127.0.0.1', 122 | 'host' => 'facebook.com', 123 | 'class' => 'IN' 124 | ])->toDNSRecord(); 125 | 126 | $this->assertEquals( 127 | DNSRecord::createFromPrimitives('A', 'facebook.com', 365, '127.0.0.1'), 128 | $mappedRecord 129 | ); 130 | } 131 | 132 | /** 133 | * @test 134 | */ 135 | public function mapsAllKindsOfLocalSystemDNSRecordVariants(): void 136 | { 137 | foreach (self::LOCAL_DNS_FORMAT as $record) { 138 | $mappedRecord = $this->mapper->mapFields($record)->toDNSRecord(); 139 | $this->assertInstanceOf(DNSRecord::class, $mappedRecord); 140 | } 141 | } 142 | 143 | /** 144 | * @test 145 | */ 146 | public function mapsRecordTypesToCorrespondingPHPConsts(): void 147 | { 148 | $this->assertEquals(32768, $this->mapper->getTypeCodeFromType(DNSRecordType::createTXT())); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /tests/Unit/Entities/DNSRecordTest.php: -------------------------------------------------------------------------------- 1 | DNSARecord = DNSRecord::createFromPrimitives( 23 | 'A', 24 | 'google.com', 25 | 123, 26 | '127.0.0.1', 27 | 'AS' 28 | ); 29 | 30 | $this->DNSTXTRecord = DNSRecord::createFromPrimitives( 31 | 'TXT', 32 | 'google.com', 33 | 123, 34 | null, 35 | 'AS', 36 | 'txtval' 37 | ); 38 | } 39 | 40 | /** 41 | * @test 42 | */ 43 | public function hasBasicGetters(): void 44 | { 45 | $this->assertSame(123, $this->DNSARecord->getTTL()); 46 | $this->assertTrue(IPAddress::createFromString('127.0.0.1')->equals($this->DNSARecord->getIPAddress())); 47 | $this->assertTrue(Hostname::createFromString('google.com')->equals($this->DNSARecord->getHostname())); 48 | $this->assertTrue(DNSRecordType::createA()->equals($this->DNSARecord->getType())); 49 | $this->assertSame('AS', $this->DNSARecord->getClass()); 50 | $this->assertNull($this->DNSARecord->getData()); 51 | } 52 | 53 | public function hasBasicSetters(): void 54 | { 55 | $this->assertSame(123, $this->DNSARecord->getTTL()); 56 | $this->assertSame(321, $this->DNSARecord->setTTL(321)->getTTL()); 57 | } 58 | 59 | /** 60 | * @test 61 | */ 62 | public function isArrayable(): void 63 | { 64 | $this->assertArrayableAndEquals([ 65 | 'hostname' => 'google.com.', 66 | 'type' => 'A', 67 | 'TTL' => 123, 68 | 'class' => 'AS', 69 | 'IPAddress' => '127.0.0.1', 70 | ], $this->DNSARecord); 71 | 72 | $this->assertArrayableAndEquals([ 73 | 'hostname' => 'google.com.', 74 | 'type' => 'TXT', 75 | 'TTL' => 123, 76 | 'class' => 'AS', 77 | 'data' => 'txtval' 78 | ], $this->DNSTXTRecord); 79 | } 80 | 81 | /** 82 | * @test 83 | */ 84 | public function isJsonSerializable(): void 85 | { 86 | $this->assertJsonSerializeableAndEquals([ 87 | 'hostname' => 'google.com.', 88 | 'type' => 'A', 89 | 'TTL' => 123, 90 | 'class' => 'AS', 91 | 'IPAddress' => '127.0.0.1', 92 | ], $this->DNSARecord); 93 | 94 | $this->assertJsonSerializeableAndEquals([ 95 | 'hostname' => 'google.com.', 96 | 'type' => 'TXT', 97 | 'TTL' => 123, 98 | 'class' => 'AS', 99 | 'data' => 'txtval' 100 | ], $this->DNSTXTRecord); 101 | } 102 | 103 | /** 104 | * @test 105 | */ 106 | public function isSerializable(): void 107 | { 108 | $this->assertSerializable($this->DNSARecord); 109 | 110 | $this->assertEquals(unserialize(serialize($this->DNSARecord)), $this->DNSARecord); 111 | $this->assertEquals(unserialize(serialize($this->DNSTXTRecord)), $this->DNSTXTRecord); 112 | } 113 | 114 | /** 115 | * @test 116 | */ 117 | public function testsEquality(): void 118 | { 119 | $record2 = DNSRecord::createFromPrimitives( 120 | 'A', 121 | 'google.com', 122 | 123, 123 | '192.168.1.1', 124 | 'AS' 125 | ); 126 | 127 | $record3 = DNSRecord::createFromPrimitives( 128 | 'A', 129 | 'google.com', 130 | 321, 131 | '127.0.0.1', 132 | 'AS' 133 | ); 134 | 135 | $record3 = DNSRecord::createFromPrimitives( 136 | 'A', 137 | 'google.com', 138 | 321, 139 | '127.0.0.1', 140 | 'AS' 141 | ); 142 | 143 | $record4 = DNSRecord::createFromPrimitives( 144 | 'TXT', 145 | 'google.com', 146 | 321, 147 | null, 148 | 'AS', 149 | 'txtval' 150 | ); 151 | 152 | $this->assertTrue($this->DNSARecord->equals($record3)); 153 | $this->assertFalse($this->DNSARecord->equals($record2)); 154 | 155 | $this->assertTrue($this->DNSTXTRecord->equals($record4)); 156 | $this->assertFalse($this->DNSTXTRecord->equals($record3)); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/Entities/DNSRecordCollection.php: -------------------------------------------------------------------------------- 1 | records = new ArrayIterator($records); 29 | } 30 | 31 | public function toArray(): array 32 | { 33 | return $this->records->getArrayCopy(); 34 | } 35 | 36 | public function pickFirst(): ?DNSRecordInterface 37 | { 38 | $copy = $this->records->getArrayCopy(); 39 | 40 | return array_shift($copy); 41 | } 42 | 43 | public function filteredByType(DNSRecordType $type): self 44 | { 45 | $fn = fn(DNSRecordInterface $record) => $record->getType()->equals($type); 46 | return new self(...array_filter($this->records->getArrayCopy(), $fn)); 47 | } 48 | 49 | public function has(DNSRecordInterface $lookupRecord): bool 50 | { 51 | foreach ($this->records->getArrayCopy() as $record) { 52 | if ($lookupRecord->equals($record)) { 53 | return true; 54 | } 55 | } 56 | 57 | return false; 58 | } 59 | 60 | public function current(): ?DNSRecordInterface 61 | { 62 | return $this->records->current(); 63 | } 64 | 65 | public function next(): void 66 | { 67 | $this->records->next(); 68 | } 69 | 70 | public function key(): bool|int|string|null 71 | { 72 | return $this->records->key(); 73 | } 74 | 75 | public function valid(): bool 76 | { 77 | return $this->records->valid(); 78 | } 79 | 80 | public function rewind(): void 81 | { 82 | $this->records->rewind(); 83 | } 84 | 85 | /** 86 | * @param mixed $offset 87 | */ 88 | public function offsetExists($offset): bool 89 | { 90 | return $this->records->offsetExists($offset); 91 | } 92 | 93 | /** 94 | * @param mixed $offset 95 | */ 96 | public function offsetGet($offset): DNSRecordInterface 97 | { 98 | return $this->records->offsetGet($offset); 99 | } 100 | 101 | /** 102 | * @param mixed $offset 103 | * @param mixed $value 104 | * @throws \RemotelyLiving\PHPDNS\Exceptions\InvalidArgumentException 105 | */ 106 | public function offsetSet($offset, $value): void 107 | { 108 | if (!$value instanceof DNSRecordInterface) { 109 | throw new InvalidArgumentException('Invalid value'); 110 | } 111 | 112 | $this->records->offsetSet($offset, /** @scrutinizer ignore-type */ $value); 113 | } 114 | 115 | /** 116 | * @param mixed $offset 117 | */ 118 | public function offsetUnset($offset): void 119 | { 120 | $this->records->offsetUnset($offset); 121 | } 122 | 123 | public function count(): int 124 | { 125 | return $this->records->count(); 126 | } 127 | 128 | public function isEmpty(): bool 129 | { 130 | return $this->count() === 0; 131 | } 132 | 133 | public function __serialize(): array 134 | { 135 | return $this->records->getArrayCopy(); 136 | } 137 | 138 | public function __unserialize(array $unserialized): void 139 | { 140 | $this->records = new ArrayIterator($unserialized); 141 | } 142 | 143 | public function jsonSerialize(): array 144 | { 145 | return $this->toArray(); 146 | } 147 | 148 | public function withUniqueValuesExcluded(): self 149 | { 150 | return $this->filterValues( 151 | fn(DNSRecordInterface $candidateRecord, DNSRecordCollection $remaining): bool => $remaining->has( 152 | $candidateRecord 153 | ) 154 | )->withUniqueValues(); 155 | } 156 | 157 | public function withUniqueValues(): self 158 | { 159 | return $this->filterValues( 160 | fn(DNSRecordInterface $candidateRecord, DNSRecordCollection $remaining): bool => !$remaining->has( 161 | $candidateRecord 162 | ) 163 | ); 164 | } 165 | 166 | private function filterValues(callable $eval): self 167 | { 168 | $filtered = new self(); 169 | $records = $this->records->getArrayCopy(); 170 | 171 | while ($record = array_shift($records)) { 172 | if ($eval($record, new self(...$records))) { 173 | $filtered[] = $record; 174 | } 175 | } 176 | 177 | return $filtered; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/Resolvers/Chain.php: -------------------------------------------------------------------------------- 1 | pushResolver($resolver); 38 | } 39 | } 40 | 41 | public function pushResolver(Resolver ...$resolvers): void 42 | { 43 | foreach ($resolvers as $resolver) { 44 | $this->resolvers[] = $resolver; 45 | } 46 | } 47 | 48 | public function withAllResults(): Interfaces\Chain 49 | { 50 | $all = new self(...$this->resolvers); 51 | $all->callThroughStrategy = self::STRATEGY_ALL_RESULTS; 52 | 53 | return $all; 54 | } 55 | 56 | public function withFirstResults(): Interfaces\Chain 57 | { 58 | $first = new self(...$this->resolvers); 59 | $first->callThroughStrategy = self::STRATEGY_FIRST_TO_FIND; 60 | 61 | return $first; 62 | } 63 | 64 | public function withConsensusResults(): Interfaces\Chain 65 | { 66 | $consensus = new self(...$this->resolvers); 67 | $consensus->callThroughStrategy = self::STRATEGY_CONSENSUS; 68 | 69 | return $consensus; 70 | } 71 | 72 | public function randomly(): Interfaces\Chain 73 | { 74 | $randomized = clone $this; 75 | shuffle($randomized->resolvers); 76 | 77 | return $randomized; 78 | } 79 | 80 | public function addSubscriber(EventSubscriberInterface $subscriber): void 81 | { 82 | foreach ($this->resolvers as $resolver) { 83 | if ($resolver instanceof Observable) { 84 | $resolver->addSubscriber($subscriber); 85 | } 86 | } 87 | } 88 | 89 | public function addListener(string $eventName, callable $listener, int $priority = 0): void 90 | { 91 | foreach ($this->resolvers as $resolver) { 92 | if ($resolver instanceof Observable) { 93 | $resolver->addListener($eventName, $listener, $priority); 94 | } 95 | } 96 | } 97 | 98 | public function setLogger(LoggerInterface $logger): void 99 | { 100 | foreach ($this->resolvers as $resolver) { 101 | if ($resolver instanceof LoggerAwareInterface) { 102 | $resolver->setLogger($logger); 103 | } 104 | } 105 | } 106 | 107 | public function hasRecord(DNSRecordInterface $record): bool 108 | { 109 | foreach ($this->resolvers as $resolver) { 110 | if ($resolver->hasRecord($record)) { 111 | return true; 112 | } 113 | } 114 | 115 | return false; 116 | } 117 | 118 | protected function doQuery(Hostname $hostname, DNSRecordType $recordType): DNSRecordCollection 119 | { 120 | $merged = []; 121 | 122 | foreach ($this->resolvers as $resolver) { 123 | try { 124 | $records = $resolver->getRecords((string)$hostname, (string)$recordType); 125 | } catch (Exception $e) { 126 | $this->getLogger()->error( 127 | 'Something went wrong in the chain of resolvers', 128 | ['exception' => json_encode($e, JSON_THROW_ON_ERROR), 'resolver' => $resolver->getName()] 129 | ); 130 | continue; 131 | } 132 | 133 | if ($this->callThroughStrategy === self::STRATEGY_FIRST_TO_FIND && !$records->isEmpty()) { 134 | return $records; 135 | } 136 | 137 | /** @var DNSRecord $record */ 138 | foreach ($records as $record) { 139 | $merged[] = $record; 140 | } 141 | } 142 | 143 | $collection = new DNSRecordCollection(...$merged); 144 | 145 | return ($this->callThroughStrategy === self::STRATEGY_CONSENSUS) 146 | ? $collection->withUniqueValuesExcluded() 147 | : $collection->withUniqueValues(); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /tests/Unit/Resolvers/CachedTest.php: -------------------------------------------------------------------------------- 1 | timestamp = time(); 42 | $this->dateTimeImmutable = $this->createMock(DateTimeImmutable::class); 43 | $this->dateTimeImmutable->method('setTimeStamp') 44 | ->willReturn($this->dateTimeImmutable); 45 | $this->dateTimeImmutable->method('getTimeStamp') 46 | ->willReturnCallback(fn() => $this->timestamp); 47 | 48 | $this->cacheItem = $this->createMock(CacheItemInterface::class); 49 | $this->cache = $this->createMock(CacheItemPoolInterface::class); 50 | $this->cache->method('getItem') 51 | ->with('a98e0fde8ccac017a92d99e669448572') 52 | ->willReturnCallback(fn() => $this->cacheItem); 53 | 54 | $this->resolver = $this->createMock(Resolver::class); 55 | 56 | $this->DNSRecord1 = DNSRecord::createFromPrimitives('A', 'example.com', 0); 57 | $this->DNSRecord2 = DNSRecord::createFromPrimitives('AAAA', 'example.com', 100); 58 | $this->DNSRecord3 = DNSRecord::createFromPrimitives('MX', 'example.com', 200); 59 | $this->DNSRecordCollection = new DNSRecordCollection(...[ 60 | $this->DNSRecord1, 61 | $this->DNSRecord2, 62 | $this->DNSRecord3 63 | ]); 64 | 65 | $this->resolver->method('getRecords') 66 | ->with('example.com.', 'ANY') 67 | ->willReturnCallback(fn(): DNSRecordCollection => $this->DNSRecordCollection); 68 | 69 | $this->cachedResolver = new Cached($this->cache, $this->resolver); 70 | $this->cachedResolver->setDateTimeImmutable($this->dateTimeImmutable); 71 | } 72 | 73 | public function testCachesUsingLowestTTLOnReturnedRecordSet(): void 74 | { 75 | $this->cacheItem->method('isHit') 76 | ->willReturn(false); 77 | 78 | $this->cacheItem->expects($this->once()) 79 | ->method('expiresAfter') 80 | ->with(100); 81 | 82 | $this->cacheItem->expects($this->once()) 83 | ->method('set') 84 | ->with(['recordCollection' => $this->DNSRecordCollection, 'timestamp' => $this->timestamp]); 85 | 86 | $this->cache->expects($this->once()) 87 | ->method('save') 88 | ->with($this->cacheItem); 89 | 90 | $this->assertEquals($this->DNSRecordCollection, $this->cachedResolver->getRecords('example.com', 'ANY')); 91 | } 92 | 93 | public function testDoesNotCacheEmptyResultsIfOptionIsSet(): void 94 | { 95 | $this->cache->expects($this->never()) 96 | ->method('save'); 97 | 98 | $this->DNSRecordCollection = new DNSRecordCollection(); 99 | 100 | $results = $this->cachedResolver->withEmptyResultCachingDisabled() 101 | ->getRecords('example.com', 'ANY'); 102 | 103 | $this->assertEquals($this->DNSRecordCollection, $results); 104 | } 105 | 106 | public function testOnHitReturnsCachedValuesAndAdjustsTTLBasedOnTimeElapsedSinceStorage(): void 107 | { 108 | $this->cacheItem->method('isHit') 109 | ->willReturn(true); 110 | 111 | $this->cacheItem->method('get') 112 | ->willReturn(['recordCollection' => $this->DNSRecordCollection, 'timestamp' => $this->timestamp - 10]); 113 | 114 | $this->cache->expects($this->never()) 115 | ->method('save'); 116 | 117 | $DNSRecord1 = DNSRecord::createFromPrimitives('A', 'example.com', 0); 118 | $DNSRecord2 = DNSRecord::createFromPrimitives('AAAA', 'example.com', 90); 119 | $DNSRecord3 = DNSRecord::createFromPrimitives('MX', 'example.com', 190); 120 | $expectedDNSRecordCollection = new DNSRecordCollection(...[ 121 | $DNSRecord1, $DNSRecord2, $DNSRecord3 122 | ]); 123 | 124 | $this->assertEquals( 125 | $expectedDNSRecordCollection, 126 | $this->cachedResolver->getRecords('example.com', 'ANY') 127 | ); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tests/Unit/Resolvers/LocalSystemTest.php: -------------------------------------------------------------------------------- 1 | dnsClient = $this->createMock(LocalSystemDNS::class); 27 | $this->localSystemMapper = new LocalSystemMapper(); 28 | $this->localSystem = new LocalSystem($this->dnsClient); 29 | $this->assertInstanceOf(ResolverAbstract::class, $this->localSystem); 30 | } 31 | 32 | /** 33 | * @test 34 | */ 35 | public function hasOrDoesNotHaveRecord() : void 36 | { 37 | $record = DNSRecord::createFromPrimitives('A', 'facebook.com', 1726, IPAddress::createFromString('192.169.1.1')); 38 | 39 | $this->dnsClient->expects($this->exactly(2)) 40 | ->method('getRecord') 41 | ->with('facebook.com', 1) 42 | ->willReturnOnConsecutiveCalls( 43 | self::getEmptyResponse(), 44 | self::buildResponse('A') 45 | ); 46 | 47 | $this->assertFalse($this->localSystem->hasRecord($record)); 48 | $this->assertTrue($this->localSystem->hasRecord($record)); 49 | } 50 | 51 | /** 52 | * @test 53 | */ 54 | public function getsHostnameByAddress() : void 55 | { 56 | $expected = Hostname::createFromString('cnn.com'); 57 | $IPAddress = IPAddress::createFromString('127.0.0.1'); 58 | 59 | $this->dnsClient->method('getHostnameByAddress') 60 | ->with('127.0.0.1') 61 | ->willReturn('cnn.com'); 62 | 63 | $this->assertEquals($expected, $this->localSystem->getHostnameByAddress($IPAddress)); 64 | } 65 | 66 | /** 67 | * @test 68 | * @dataProvider dnsQueryInterfaceMessageProvider 69 | */ 70 | public function getsRecords(string $method, Hostname $hostname, DNSRecordType $type, array $response, $expected) 71 | { 72 | $this->dnsClient->expects($this->once()) 73 | ->method('getRecord') 74 | ->with($hostname->getHostnameWithoutTrailingDot(), $this->localSystemMapper->getTypeCodeFromType($type)) 75 | ->willReturn($response); 76 | 77 | $actual = $this->localSystem->{$method}($hostname, $type); 78 | 79 | $this->assertEquals($expected, $actual); 80 | } 81 | 82 | public function dnsQueryInterfaceMessageProvider(): array 83 | { 84 | $ARecord = DNSRecord::createFromPrimitives( 85 | 'A', 86 | 'facebook.com', 87 | 375, 88 | IPAddress::createFromString('192.169.1.1') 89 | ); 90 | 91 | $CNAMERecord = DNSRecord::createFromPrimitives( 92 | 'CNAME', 93 | 'facebook.com', 94 | 375, 95 | IPAddress::createFromString('192.169.1.1') 96 | ); 97 | 98 | $MXRecord = DNSRecord::createFromPrimitives( 99 | 'MX', 100 | 'facebook.com', 101 | 375, 102 | IPAddress::createFromString('192.169.1.1') 103 | ); 104 | 105 | $hostname = Hostname::createFromString('facebook.com'); 106 | 107 | return [ 108 | ['getARecords', $hostname, DNSRecordType::createA(), self::buildResponse('A'), new DNSRecordCollection($ARecord)], 109 | ['getAAAARecords',$hostname, DNSRecordType::createAAAA(), self::getEmptyResponse(), new DNSRecordCollection()], 110 | ['getCNAMERecords',$hostname, DNSRecordType::createCNAME(), self::buildResponse('CNAME'), new DNSRecordCollection($CNAMERecord)], 111 | ['getTXTRecords', $hostname, DNSRecordType::createTXT(), self::getEmptyResponse(), new DNSRecordCollection()], 112 | ['getMXRecords', $hostname, DNSRecordType::createMX(), self::buildResponse('MX'), new DNSRecordCollection($MXRecord)], 113 | ['recordTypeExists', $hostname, DNSRecordType::createMX(), self::buildResponse('MX'), true], 114 | ['recordTypeExists', $hostname, DNSRecordType::createMX(), self::getEmptyResponse(), false], 115 | ]; 116 | } 117 | 118 | public static function buildResponse(string $type): array 119 | { 120 | return [ 121 | [ 122 | 'host' => 'facebook.com', 123 | 'class' => 'IN', 124 | 'ttl' => 375, 125 | 'type' => $type, 126 | 'ipv6' => '192.169.1.1', 127 | ], 128 | ]; 129 | } 130 | 131 | public static function getEmptyResponse(): array 132 | { 133 | return []; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Entities/DNSRecordType.php: -------------------------------------------------------------------------------- 1 | DNSRecordType::TYPE_A, 47 | 5 => DNSRecordType::TYPE_CNAME, 48 | 13 => DNSRecordType::TYPE_HINFO, 49 | 257 => DNSRecordType::TYPE_CAA, 50 | 15 => DNSRecordType::TYPE_MX, 51 | 2 => DNSRecordType::TYPE_NS, 52 | 12 => DNSRecordType::TYPE_PTR, 53 | 6 => DNSRecordType::TYPE_SOA, 54 | 16 => DNSRecordType::TYPE_TXT, 55 | 28 => DNSRecordType::TYPE_AAAA, 56 | 33 => DNSRecordType::TYPE_SRV, 57 | 35 => DNSRecordType::TYPE_NAPTR, 58 | 38 => DNSRecordType::TYPE_A6, 59 | 255 => DNSRecordType::TYPE_ANY, 60 | ]; 61 | 62 | private string $type; 63 | 64 | /** 65 | * @throws \RemotelyLiving\PHPDNS\Exceptions\InvalidArgumentException 66 | */ 67 | public function __construct(string $type) 68 | { 69 | if (!in_array($type, self::VALID_TYPES, true)) { 70 | throw new InvalidArgumentException("{$type} is not an existing DNS record type"); 71 | } 72 | 73 | $this->type = $type; 74 | } 75 | 76 | public function __toString(): string 77 | { 78 | return $this->type; 79 | } 80 | 81 | /** 82 | * @throws \RemotelyLiving\PHPDNS\Exceptions\InvalidArgumentException 83 | */ 84 | public static function createFromInt(int $code): DNSRecordType 85 | { 86 | if (!isset(self::CODE_TYPE_MAP[$code])) { 87 | throw new InvalidArgumentException("{$code} is not able to be mapped to an existing DNS record type"); 88 | } 89 | 90 | return new self(self::CODE_TYPE_MAP[$code]); 91 | } 92 | 93 | public static function createFromString(string $type): DNSRecordType 94 | { 95 | return new self($type); 96 | } 97 | 98 | public function toInt(): int 99 | { 100 | return array_flip(self::CODE_TYPE_MAP)[$this->type]; 101 | } 102 | 103 | public function isA(string $type): bool 104 | { 105 | return $this->type === strtoupper($type); 106 | } 107 | 108 | public function equals(DNSRecordType $recordType): bool 109 | { 110 | return $this->type === (string)$recordType; 111 | } 112 | 113 | public static function createA(): self 114 | { 115 | return self::createFromString(self::TYPE_A); 116 | } 117 | 118 | public static function createCNAME(): self 119 | { 120 | return self::createFromString(self::TYPE_CNAME); 121 | } 122 | 123 | public static function createHINFO(): self 124 | { 125 | return self::createFromString(self::TYPE_HINFO); 126 | } 127 | 128 | public static function createCAA(): self 129 | { 130 | return self::createFromString(self::TYPE_CAA); 131 | } 132 | 133 | public static function createMX(): self 134 | { 135 | return self::createFromString(self::TYPE_MX); 136 | } 137 | 138 | public static function createNS(): self 139 | { 140 | return self::createFromString(self::TYPE_NS); 141 | } 142 | 143 | public static function createPTR(): self 144 | { 145 | return self::createFromString(self::TYPE_PTR); 146 | } 147 | 148 | public static function createSOA(): self 149 | { 150 | return self::createFromString(self::TYPE_SOA); 151 | } 152 | 153 | public static function createTXT(): self 154 | { 155 | return self::createFromString(self::TYPE_TXT); 156 | } 157 | 158 | public static function createAAAA(): self 159 | { 160 | return self::createFromString(self::TYPE_AAAA); 161 | } 162 | 163 | public static function createSRV(): self 164 | { 165 | return self::createFromString(self::TYPE_SRV); 166 | } 167 | 168 | public static function createNAPTR(): self 169 | { 170 | return self::createFromString(self::TYPE_NAPTR); 171 | } 172 | 173 | public static function createA6(): self 174 | { 175 | return self::createFromString(self::TYPE_A6); 176 | } 177 | 178 | public static function createANY(): self 179 | { 180 | return self::createFromString(self::TYPE_ANY); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /tests/Unit/Entities/DataAbstractTest.php: -------------------------------------------------------------------------------- 1 | dataAbstract1 = new class extends DataAbstract implements \Stringable { 21 | public function __toString(): string 22 | { 23 | return 'dataAbstract1'; 24 | } 25 | 26 | public function toArray(): array 27 | { 28 | return []; 29 | } 30 | 31 | public function __serialize(): array 32 | { 33 | return ['seralized']; 34 | } 35 | 36 | public function __unserialize(array $serialized): void 37 | { 38 | } 39 | }; 40 | 41 | $this->dataAbstract2 = new class extends DataAbstract implements \Stringable { 42 | public function __toString(): string 43 | { 44 | return 'dataAbstract2'; 45 | } 46 | 47 | public function toArray(): array 48 | { 49 | return []; 50 | } 51 | 52 | public function __serialize(): array 53 | { 54 | return ['seralized']; 55 | } 56 | 57 | public function __unserialize(array $serialized): void 58 | { 59 | } 60 | }; 61 | } 62 | 63 | /** 64 | * @test 65 | */ 66 | public function knowsIfEquals(): void 67 | { 68 | $this->assertTrue($this->dataAbstract1->equals($this->dataAbstract1)); 69 | $this->assertFalse($this->dataAbstract1->equals($this->dataAbstract2)); 70 | } 71 | 72 | /** 73 | * @test 74 | */ 75 | public function createsDataByType(): void 76 | { 77 | /** @var \RemotelyLiving\PHPDNS\Entities\TXTData $txtData */ 78 | $txtData = $this->dataAbstract1::createFromTypeAndString(DNSRecordType::createTXT(), 'value'); 79 | $this->assertSame('value', $txtData->getValue()); 80 | 81 | /** @var \RemotelyLiving\PHPDNS\Entities\MXData $mxData */ 82 | $mxData = $this->dataAbstract1::createFromTypeAndString(DNSRecordType::createMX(), '60 target.com'); 83 | $this->assertSame('target.com.', (string)$mxData->getTarget()); 84 | $this->assertSame(60, $mxData->getPriority()); 85 | 86 | /** @var \RemotelyLiving\PHPDNS\Entities\NSData $nsData */ 87 | $nsData = $this->dataAbstract1::createFromTypeAndString(DNSRecordType::createNS(), 'target.com'); 88 | $this->assertSame('target.com.', (string)$nsData->getTarget()); 89 | 90 | $soaString = 'ns1.google.com. dns-admin.google.com. 224049761 900 800 1800 60'; 91 | $soaData = $this->dataAbstract1::createFromTypeAndString(DNSRecordType::createSOA(), $soaString); 92 | $this->assertSame('ns1.google.com.', (string)$soaData->getMname()); 93 | $this->assertSame('dns-admin.google.com.', (string)$soaData->getRname()); 94 | $this->assertSame(224_049_761, $soaData->getSerial()); 95 | $this->assertSame(900, $soaData->getRefresh()); 96 | $this->assertSame(800, $soaData->getRetry()); 97 | $this->assertSame(1800, $soaData->getExpire()); 98 | $this->assertSame(60, $soaData->getMinTTL()); 99 | 100 | $cnameString = 'herp.website'; 101 | $cnameData = $this->dataAbstract1::createFromTypeAndString(DNSRecordType::createCNAME(), $cnameString); 102 | $this->assertSame('herp.website.', (string)$cnameData->getHostname()); 103 | 104 | $caaString = '0 issue "comodoca.com"'; 105 | $caaData = $this->dataAbstract1::createFromTypeAndString(DNSRecordType::createCAA(), $caaString); 106 | $this->assertSame('comodoca.com', $caaData->getValue()); 107 | $this->assertSame(0, $caaData->getFlags()); 108 | $this->assertSame('issue', $caaData->getTag()); 109 | 110 | $srvString = '100 200 9090 target.co.'; 111 | $srvData = $this->dataAbstract1::createFromTypeAndString(DNSRecordType::createSRV(), $srvString); 112 | $this->assertSame(100, $srvData->getPriority()); 113 | $this->assertSame(200, $srvData->getWeight()); 114 | $this->assertSame(9090, $srvData->getPort()); 115 | $this->assertSame('target.co.', (string)$srvData->getTarget()); 116 | } 117 | 118 | /** 119 | * @test 120 | */ 121 | public function createsDataByTypeOrThrows(): void 122 | { 123 | $this->expectException(InvalidArgumentException::class); 124 | $this->dataAbstract1::createFromTypeAndString(DNSRecordType::createA(), ''); 125 | } 126 | 127 | /** 128 | * @test 129 | */ 130 | public function checksCAADataAndThrowsIfTooManySegments(): void 131 | { 132 | $this->expectException(InvalidArgumentException::class); 133 | // example of bad data from Cloudflare API 134 | $invalid = '0 issue \\# 26 00 09 69 73 73 75 65 77 69 6c 64 6c 65 74 73 65 6e 63 72 79 70 74 2e 6f 72 67'; 135 | $this->dataAbstract1::createFromTypeAndString(DNSRecordType::createCAA(), $invalid); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Resolvers/ResolverAbstract.php: -------------------------------------------------------------------------------- 1 | name)) { 42 | $explodedClass = explode('\\', $this::class); 43 | $this->name = (string) array_pop($explodedClass); 44 | } 45 | 46 | return $this->name; 47 | } 48 | 49 | public function getARecords(string $hostname): DNSRecordCollection 50 | { 51 | return $this->getRecords($hostname, (string)DNSRecordType::createA()); 52 | } 53 | 54 | public function getAAAARecords(string $hostname): DNSRecordCollection 55 | { 56 | return $this->getRecords($hostname, (string)DNSRecordType::createAAAA()); 57 | } 58 | 59 | public function getCNAMERecords(string $hostname): DNSRecordCollection 60 | { 61 | return $this->getRecords($hostname, (string)DNSRecordType::createCNAME()); 62 | } 63 | 64 | public function getTXTRecords(string $hostname): DNSRecordCollection 65 | { 66 | return $this->getRecords($hostname, (string)DNSRecordType::createTXT()); 67 | } 68 | 69 | public function getMXRecords(string $hostname): DNSRecordCollection 70 | { 71 | return $this->getRecords($hostname, (string)DNSRecordType::createMX()); 72 | } 73 | 74 | public function recordTypeExists(string $hostname, string $recordType): bool 75 | { 76 | return !$this->getRecords($hostname, $recordType)->isEmpty(); 77 | } 78 | 79 | public function hasRecord(DNSRecordInterface $record): bool 80 | { 81 | return $this->getRecords((string)$record->getHostname(), (string)$record->getType()) 82 | ->has($record); 83 | } 84 | 85 | /** 86 | * @throws \RemotelyLiving\PHPDNS\Resolvers\Exceptions\QueryFailure 87 | */ 88 | public function getRecords(string $hostname, string $recordType = null): DNSRecordCollection 89 | { 90 | $recordType = DNSRecordType::createFromString($recordType ?? 'ANY'); 91 | $hostname = Hostname::createFromString($hostname); 92 | 93 | $profile = $this->createProfile("{$this->getName()}:{$hostname}:{$recordType}"); 94 | $profile->startTransaction(); 95 | 96 | try { 97 | $result = ($recordType->equals(DNSRecordType::createANY())) 98 | ? $this->doQuery($hostname, $recordType) 99 | : $this->doQuery($hostname, $recordType)->filteredByType($recordType); 100 | } catch (QueryFailure $e) { 101 | $dnsQueryFailureEvent = new DNSQueryFailed($this, $hostname, $recordType, $e); 102 | $this->dispatch($dnsQueryFailureEvent); 103 | $this->getLogger()->error( 104 | 'DNS query failed', 105 | [self::EVENT => json_encode($dnsQueryFailureEvent, JSON_THROW_ON_ERROR), 'exception' => $e] 106 | ); 107 | 108 | throw $e; 109 | } finally { 110 | $profile->endTransaction(); 111 | $profile->samplePeakMemoryUsage(); 112 | $dnsQueryProfiledEvent = new DNSQueryProfiled($profile); 113 | $this->dispatch($dnsQueryProfiledEvent); 114 | $this->getLogger()->info('DNS query profiled', [self::EVENT => json_encode($dnsQueryProfiledEvent)]); 115 | } 116 | 117 | $dnsQueriedEvent = new DNSQueried($this, $hostname, $recordType, $result); 118 | $this->dispatch($dnsQueriedEvent); 119 | $this->getLogger()->info('DNS queried', [self::EVENT => json_encode($dnsQueriedEvent, JSON_THROW_ON_ERROR)]); 120 | return $result; 121 | } 122 | 123 | public function mapResults(MapperInterface $mapper, array $results): DNSRecordCollection 124 | { 125 | $collection = new DNSRecordCollection(); 126 | array_map(function (array $fields) use (&$collection, $mapper) { 127 | try { 128 | $collection[] = $mapper->mapFields($fields)->toDNSRecord(); 129 | } catch (InvalidArgumentException) { 130 | $this->getLogger()->warning('Invalid fields passed to mapper', $fields); 131 | } 132 | }, $results); 133 | 134 | return $collection; 135 | } 136 | 137 | /** 138 | * @throws \RemotelyLiving\PHPDNS\Resolvers\Exceptions\QueryFailure 139 | */ 140 | abstract protected function doQuery(Hostname $hostname, DNSRecordType $recordType): DNSRecordCollection; 141 | } 142 | -------------------------------------------------------------------------------- /tests/Unit/Entities/DNSRecordCollectionTest.php: -------------------------------------------------------------------------------- 1 | dnsRecord1 = DNSRecord::createFromPrimitives( 28 | 'A', 29 | 'google.com', 30 | 123, 31 | '127.0.0.1', 32 | 'AS' 33 | ); 34 | 35 | $this->dnsRecord2 = DNSRecord::createFromPrimitives( 36 | 'CNAME', 37 | 'google.com', 38 | 123, 39 | '127.0.0.1', 40 | 'IN' 41 | ); 42 | 43 | $this->dnsRecordCollection = new DNSRecordCollection($this->dnsRecord1, $this->dnsRecord2); 44 | } 45 | 46 | /** 47 | * @test 48 | */ 49 | public function isTraversable(): void 50 | { 51 | $this->assertTrue(true); 52 | } 53 | 54 | /** 55 | * @test 56 | */ 57 | public function isSerializable(): void 58 | { 59 | $this->assertSerializable($this->dnsRecordCollection); 60 | } 61 | 62 | /** 63 | * @test 64 | */ 65 | public function picksFirst(): void 66 | { 67 | $this->assertEquals($this->dnsRecord1, $this->dnsRecordCollection->pickFirst()); 68 | $this->assertEquals($this->dnsRecord1, $this->dnsRecordCollection->pickFirst()); 69 | $this->assertSame(0, $this->dnsRecordCollection->key()); 70 | } 71 | 72 | /** 73 | * @test 74 | */ 75 | public function filtersByType(): void 76 | { 77 | $filtered = $this->dnsRecordCollection->filteredByType(DNSRecordType::createCNAME()); 78 | $this->assertEquals($this->dnsRecord2, iterator_to_array($filtered)[0]); 79 | 80 | $nothing = $this->dnsRecordCollection->filteredByType(DNSRecordType::createTXT()); 81 | 82 | $this->assertFalse($nothing->valid()); 83 | } 84 | 85 | /** 86 | * @test 87 | */ 88 | public function hasARecord(): void 89 | { 90 | $notInCollection = DNSRecord::createFromPrimitives('A', 'facebook.com', 3434); 91 | 92 | $this->assertFalse($this->dnsRecordCollection->has($notInCollection)); 93 | $this->assertTrue($this->dnsRecordCollection->has($this->dnsRecord1)); 94 | $this->assertTrue($this->dnsRecordCollection->has($this->dnsRecord2)); 95 | } 96 | 97 | /** 98 | * @test 99 | */ 100 | public function isCountableTraversableIteratable(): void 101 | { 102 | $this->assertInstanceOf(Traversable::class, $this->dnsRecordCollection); 103 | $this->assertInstanceOf(Countable::class, $this->dnsRecordCollection); 104 | $this->assertInstanceOf(Iterator::class, $this->dnsRecordCollection); 105 | 106 | foreach ($this->dnsRecordCollection as $record) { 107 | $this->assertInstanceOf(DNSRecord::class, $record); 108 | } 109 | 110 | $this->assertFalse(isset($this->dnsRecordCollection[2])); 111 | 112 | $this->assertFalse($this->dnsRecordCollection->isEmpty()); 113 | 114 | $this->dnsRecordCollection[2] = DNSRecord::createFromPrimitives('A', 'facebook.com', 3434); 115 | 116 | $this->assertSame(3, count($this->dnsRecordCollection)); 117 | 118 | $this->assertEquals($this->dnsRecord1, $this->dnsRecordCollection->offsetGet(0)); 119 | 120 | unset($this->dnsRecordCollection[0], $this->dnsRecordCollection[1], $this->dnsRecordCollection[2]); 121 | 122 | $this->assertTrue($this->dnsRecordCollection->isEmpty()); 123 | 124 | $this->dnsRecordCollection->offsetSet(0, DNSRecord::createFromPrimitives('A', 'facebook.com', 3434)); 125 | 126 | $this->assertEquals( 127 | DNSRecord::createFromPrimitives('A', 'facebook.com', 3434), 128 | $this->dnsRecordCollection->offsetGet(0) 129 | ); 130 | 131 | $this->assertFalse((bool)$this->dnsRecordCollection->key()); 132 | } 133 | 134 | public function testOnlyAllowsDNSRecordsToBeSet(): void 135 | { 136 | $this->expectException(InvalidArgumentException::class); 137 | $this->dnsRecordCollection[0] = 'boop'; 138 | } 139 | 140 | /** 141 | * @test 142 | */ 143 | public function isArrayable(): void 144 | { 145 | $this->assertArrayableAndEquals([$this->dnsRecord1, $this->dnsRecord2], $this->dnsRecordCollection); 146 | } 147 | 148 | /** 149 | * @test 150 | */ 151 | public function isJsonSerializable(): void 152 | { 153 | $this->assertJsonSerializeableAndEquals([$this->dnsRecord1, $this->dnsRecord2], $this->dnsRecordCollection); 154 | } 155 | 156 | /** 157 | * @test 158 | */ 159 | public function hasFilterMethods(): void 160 | { 161 | $expectedDupes = new DNSRecordCollection($this->dnsRecord2); 162 | $expectedUniques = new DNSRecordCollection($this->dnsRecord1, $this->dnsRecord2); 163 | $expectedOnlyOneResult = new DNSRecordCollection($this->dnsRecord1); 164 | 165 | $hasDupes = new DNSRecordCollection($this->dnsRecord1, $this->dnsRecord2, $this->dnsRecord2, $this->dnsRecord2); 166 | $hasOneResult = new DNSRecordCollection($this->dnsRecord1); 167 | 168 | $this->assertEquals($expectedDupes, $hasDupes->withUniqueValuesExcluded()); 169 | $this->assertEquals($expectedUniques, $hasDupes->withUniqueValues()); 170 | 171 | $this->assertEquals($expectedOnlyOneResult, $hasOneResult->withUniqueValues()); 172 | $this->assertEquals(new DNSRecordCollection(), $hasOneResult->withUniqueValuesExcluded()); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /tests/Unit/Resolvers/ResolverAbstractTest.php: -------------------------------------------------------------------------------- 1 | logger = $this->createMock(LoggerInterface::class); 34 | $this->resolver = new TestResolver($this->collection, $this->error); 35 | } 36 | 37 | /** 38 | * @test 39 | */ 40 | public function isAnObservableResolver() : void 41 | { 42 | $this->assertInstanceOf(ResolverAbstract::class, $this->resolver); 43 | $this->assertInstanceOf(ObservableResolver::class, $this->resolver); 44 | } 45 | 46 | /** 47 | * @test 48 | */ 49 | public function mapsResultsReturnsCollection() : void 50 | { 51 | $dnsRecord = DNSRecord::createFromPrimitives('A', 'boop.com', 123); 52 | $expected = new DNSRecordCollection($dnsRecord); 53 | $results =[['type' => 'A', 'ip' => '192.168.1.1', 'class' => 'IN']]; 54 | $mapper = $this->createMock(MapperInterface::class); 55 | $mapper->method('mapFields') 56 | ->with($results[0]) 57 | ->willReturn($mapper); 58 | 59 | $mapper->method('toDNSRecord') 60 | ->willReturn($dnsRecord); 61 | 62 | $this->assertEquals($expected, $this->resolver->mapResults($mapper, $results)); 63 | 64 | } 65 | 66 | /** 67 | * @test 68 | */ 69 | public function mapsResultsAndDiscardsInvalidData() : void 70 | { 71 | $expected = new DNSRecordCollection(); 72 | $results =[['type' => 'BAZ', 'ip' => '192.168.1.1', 'class' => 'IN']]; 73 | $mapper = $this->createMock(MapperInterface::class); 74 | $mapper->method('mapFields') 75 | ->with($results[0]) 76 | ->willReturn($mapper); 77 | 78 | $mapper->method('toDNSRecord') 79 | ->willThrowException(new InvalidArgumentException()); 80 | 81 | $this->assertEquals($expected, $this->resolver->mapResults($mapper, $results)); 82 | 83 | } 84 | 85 | /** 86 | * @test 87 | */ 88 | public function dispatchedEventsOnSuccessfulQuery() : void 89 | { 90 | $dnsQueried = null; 91 | $perProfiled = null; 92 | 93 | $this->resolver->addListener(DNSQueried::getName(), function (DNSQueried $event) use (&$dnsQueried) { 94 | $dnsQueried = $event; 95 | }); 96 | 97 | $this->resolver->addListener(DNSQueryProfiled::getName(), function (DNSQueryProfiled $event) use (&$perProfiled) { 98 | $perProfiled = $event; 99 | }); 100 | 101 | $this->resolver->getRecords(Hostname::createFromString('facebook.com')); 102 | 103 | $this->assertInstanceOf(DNSQueryProfiled::class, $perProfiled); 104 | $this->assertInstanceOf(DNSQueried::class, $dnsQueried); 105 | } 106 | 107 | /** 108 | * @test 109 | */ 110 | public function logsSuccessfulEvents() : void 111 | { 112 | $this->resolver->setLogger($this->logger); 113 | 114 | $this->logger->expects($this->exactly(2)) 115 | ->method('info') 116 | ->willReturnCallback(function (string $message, array $context) { 117 | $this->assertStringStartsWith('DNS', $message); 118 | $this->assertArrayHasKey('event', $context); 119 | }); 120 | 121 | $this->resolver->getRecords('facebook.com'); 122 | } 123 | 124 | /** 125 | * @test 126 | */ 127 | public function logsFailures() : void 128 | { 129 | $this->expectException(QueryFailure::class); 130 | 131 | $this->resolver = new TestResolver(null, new QueryFailure()); 132 | $this->resolver->setLogger($this->logger); 133 | 134 | $this->logger->expects($this->once()) 135 | ->method('info') 136 | ->willReturnCallback(function (string $message, array $context) { 137 | $this->assertStringStartsWith('DNS', $message); 138 | $this->assertArrayHasKey('event', $context); 139 | }); 140 | 141 | $this->logger->expects($this->once()) 142 | ->method('error') 143 | ->willReturnCallback(function (string $message, array $context) { 144 | $this->assertStringStartsWith('DNS', $message); 145 | $this->assertArrayHasKey('event', $context); 146 | }); 147 | 148 | $this->resolver->getRecords('facebook.com'); 149 | } 150 | 151 | /** 152 | * @test 153 | */ 154 | public function dispatchedEventsOnQueryFailure() : void 155 | { 156 | $dnsQueryFailed = null; 157 | $perProfiled = null; 158 | 159 | $resolver = new TestResolver(null, new QueryFailure()); 160 | $resolver->addListener(DNSQueryFailed::getName(), function (DNSQueryFailed $event) use (&$dnsQueryFailed) { 161 | $dnsQueryFailed = $event; 162 | }); 163 | 164 | $resolver->addListener(DNSQueryProfiled::getName(), function (DNSQueryProfiled $event) use (&$perProfiled) { 165 | $perProfiled = $event; 166 | }); 167 | 168 | try { 169 | $resolver->getRecords(Hostname::createFromString('facebook.com')); 170 | } catch (QueryFailure) {} 171 | 172 | $this->assertInstanceOf(DNSQueryProfiled::class, $perProfiled); 173 | $this->assertInstanceOf(DNSQueryFailed::class, $dnsQueryFailed); 174 | } 175 | } 176 | --------------------------------------------------------------------------------