├── .github └── workflows │ ├── pest.yml │ └── php-cs-fixer.yml ├── .php-cs-fixer.dist.php ├── .php-cs-fixer.laravel.php ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── Casts │ └── DomainCast.php ├── Dns.php ├── DnsServiceProvider.php ├── Domain.php ├── Facades │ └── Dns.php └── Rules │ └── DnsRecordExists.php └── tests ├── DnsRecordExistsTest.php ├── DnsTest.php ├── DomainCastTest.php ├── DomainTest.php ├── Models └── Team.php ├── Pest.php └── TestCase.php /.github/workflows/pest.yml: -------------------------------------------------------------------------------- 1 | name: "pest" 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | pest: 9 | runs-on: "ubuntu-latest" 10 | strategy: 11 | fail-fast: true 12 | matrix: 13 | php: ["8.1", "8.2"] 14 | laravel: ["^9.0", "^10.0"] 15 | stability: ["prefer-lowest", "prefer-stable"] 16 | 17 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php }} 25 | coverage: xdebug 26 | 27 | - run: | 28 | composer require --dev "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update 29 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 30 | 31 | - run: vendor/bin/pest 32 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: "php-cs-fixer" 2 | 3 | on: [push] 4 | 5 | jobs: 6 | php-cs-fixer: 7 | runs-on: "ubuntu-latest" 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - uses: docker://oskarstark/php-cs-fixer-ga 13 | with: 14 | args: "--allow-risky=yes --using-cache=no" 15 | 16 | - uses: stefanzweifel/git-auto-commit-action@v4 17 | with: 18 | commit_message: "php-cs-fixer" -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | notPath('vendor') 5 | ->ignoreDotFiles(true) 6 | ->ignoreVCS(true) 7 | ->name('*.php') 8 | ->in(__DIR__); 9 | 10 | return (new \PhpCsFixer\Config()) 11 | ->setRules(array_merge(require '.php-cs-fixer.laravel.php', [ 12 | '@PSR2' => true, 13 | '@PSR12' => true, 14 | 'no_unused_imports' => true, 15 | 'phpdoc_to_comment' => false, 16 | 'phpdoc_order' => true, 17 | 'phpdoc_separation' => true, 18 | 'simplified_null_return' => false, 19 | ])) 20 | ->setLineEnding("\n") 21 | ->setIndent(str_repeat(' ', 4)) 22 | ->setUsingCache(false) 23 | ->setRiskyAllowed(true) 24 | ->setFinder($finder); 25 | -------------------------------------------------------------------------------- /.php-cs-fixer.laravel.php: -------------------------------------------------------------------------------- 1 | ['syntax' => 'short'], 9 | 'binary_operator_spaces' => [ 10 | 'default' => 'single_space', 11 | 'operators' => ['=>' => null] 12 | ], 13 | 'blank_line_after_namespace' => true, 14 | 'blank_line_after_opening_tag' => true, 15 | 'blank_line_before_statement' => [ 16 | 'statements' => ['return'] 17 | ], 18 | 'braces' => true, 19 | 'cast_spaces' => true, 20 | 'class_attributes_separation' => [ 21 | 'elements' => [ 22 | 'const' => 'one', 23 | 'method' => 'one', 24 | 'property' => 'one', 25 | ], 26 | ], 27 | 'class_definition' => true, 28 | 'concat_space' => [ 29 | 'spacing' => 'none' 30 | ], 31 | 'declare_equal_normalize' => true, 32 | 'elseif' => true, 33 | 'encoding' => true, 34 | 'full_opening_tag' => true, 35 | 'fully_qualified_strict_types' => true, // added by Shift 36 | 'function_declaration' => true, 37 | 'function_typehint_space' => true, 38 | 'heredoc_to_nowdoc' => true, 39 | 'include' => true, 40 | 'increment_style' => ['style' => 'post'], 41 | 'indentation_type' => true, 42 | 'linebreak_after_opening_tag' => true, 43 | 'line_ending' => true, 44 | 'lowercase_cast' => true, 45 | 'lowercase_keywords' => true, 46 | 'lowercase_static_reference' => true, // added from Symfony 47 | 'magic_method_casing' => true, // added from Symfony 48 | 'magic_constant_casing' => true, 49 | 'method_argument_space' => true, 50 | 'native_function_casing' => true, 51 | 'no_alias_functions' => true, 52 | 'no_extra_blank_lines' => [ 53 | 'tokens' => [ 54 | 'extra', 55 | 'throw', 56 | 'use', 57 | 'use_trait', 58 | ] 59 | ], 60 | 'no_blank_lines_after_class_opening' => true, 61 | 'no_blank_lines_after_phpdoc' => true, 62 | 'no_closing_tag' => true, 63 | 'no_empty_phpdoc' => true, 64 | 'no_empty_statement' => true, 65 | 'no_leading_import_slash' => true, 66 | 'no_leading_namespace_whitespace' => true, 67 | 'no_mixed_echo_print' => [ 68 | 'use' => 'echo' 69 | ], 70 | 'no_multiline_whitespace_around_double_arrow' => true, 71 | 'multiline_whitespace_before_semicolons' => [ 72 | 'strategy' => 'no_multi_line' 73 | ], 74 | 'no_short_bool_cast' => true, 75 | 'no_singleline_whitespace_before_semicolons' => true, 76 | 'no_spaces_after_function_name' => true, 77 | 'no_spaces_around_offset' => true, 78 | 'no_spaces_inside_parenthesis' => true, 79 | 'no_trailing_comma_in_list_call' => true, 80 | 'no_trailing_comma_in_singleline_array' => true, 81 | 'no_trailing_whitespace' => true, 82 | 'no_trailing_whitespace_in_comment' => true, 83 | 'no_unneeded_control_parentheses' => true, 84 | 'no_unreachable_default_argument_value' => true, 85 | 'no_useless_return' => true, 86 | 'no_whitespace_before_comma_in_array' => true, 87 | 'no_whitespace_in_blank_line' => true, 88 | 'normalize_index_brace' => true, 89 | 'not_operator_with_successor_space' => true, 90 | 'object_operator_without_whitespace' => true, 91 | 'ordered_imports' => ['sortAlgorithm' => 'alpha'], 92 | 'phpdoc_indent' => true, 93 | 'phpdoc_no_access' => true, 94 | 'phpdoc_no_package' => true, 95 | 'phpdoc_no_useless_inheritdoc' => true, 96 | 'phpdoc_scalar' => true, 97 | 'phpdoc_single_line_var_spacing' => true, 98 | 'phpdoc_summary' => true, 99 | 'phpdoc_to_comment' => true, 100 | 'phpdoc_trim' => true, 101 | 'phpdoc_types' => true, 102 | 'phpdoc_var_without_name' => true, 103 | 'psr_autoloading' => true, 104 | 'self_accessor' => true, 105 | 'short_scalar_cast' => true, 106 | 'simplified_null_return' => true, 107 | 'single_blank_line_at_eof' => true, 108 | 'blank_lines_before_namespace' => true, 109 | 'single_class_element_per_statement' => true, 110 | 'single_import_per_statement' => true, 111 | 'single_line_after_imports' => true, 112 | 'single_line_comment_style' => [ 113 | 'comment_types' => ['hash'] 114 | ], 115 | 'single_quote' => true, 116 | 'space_after_semicolon' => true, 117 | 'standardize_not_equals' => true, 118 | 'switch_case_semicolon_to_colon' => true, 119 | 'switch_case_space' => true, 120 | 'ternary_operator_spaces' => true, 121 | 'trailing_comma_in_multiline' => true, 122 | 'trim_array_spaces' => true, 123 | 'unary_operator_spaces' => true, 124 | 'visibility_required' => [ 125 | 'elements' => ['method', 'property'] 126 | ], 127 | 'whitespace_after_comma_in_array' => true, 128 | ]; 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Astrotomic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel DNS 2 | 3 | [![Latest Version](http://img.shields.io/packagist/v/astrotomic/laravel-dns.svg?label=Release&style=for-the-badge)](https://packagist.org/packages/astrotomic/laravel-dns) 4 | [![MIT License](https://img.shields.io/github/license/Astrotomic/laravel-dns.svg?label=License&color=blue&style=for-the-badge)](https://github.com/Astrotomic/laravel-dns/blob/master/LICENSE) 5 | [![Offset Earth](https://img.shields.io/badge/Treeware-%F0%9F%8C%B3-green?style=for-the-badge)](https://plant.treeware.earth/Astrotomic/laravel-dns) 6 | [![Larabelles](https://img.shields.io/badge/Larabelles-%F0%9F%A6%84-lightpink?style=for-the-badge)](https://www.larabelles.com/) 7 | 8 | ![](https://img.shields.io/badge/PHP-^8.0-777BB4?style=for-the-badge&logo=php&logoColor=white) 9 | ![](https://img.shields.io/badge/Laravel-^8.0-FF2D20?style=for-the-badge&logo=laravel&logoColor=white) 10 | 11 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/Astrotomic/laravel-dns/pest?style=flat-square&logoColor=white&logo=github&label=Tests)](https://github.com/Astrotomic/laravel-dns/actions?query=workflow%3Apest) 12 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/Astrotomic/laravel-dns/php-cs-fixer?style=flat-square&logoColor=white&logo=github&label=Code+Style)](https://github.com/Astrotomic/laravel-dns/actions?query=workflow%3Aphp-cs-fixer) 13 | [![Total Downloads](https://img.shields.io/packagist/dt/astrotomic/laravel-dns.svg?label=Downloads&style=flat-square)](https://packagist.org/packages/astrotomic/laravel-dns) 14 | 15 | ## Installation 16 | 17 | ```bash 18 | composer require astrotomic/laravel-dns 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```php 24 | use Astrotomic\Dns\Facades\Dns; 25 | 26 | /** @var \Illuminate\Support\Collection $records */ 27 | $records = Dns::records('astrotomic.info', DNS_A); 28 | ``` 29 | 30 | ```php 31 | use Astrotomic\Dns\Rules\DnsRecordExists; 32 | use Spatie\Dns\Records\A; 33 | use Spatie\Dns\Records\TXT; 34 | 35 | return [ 36 | 'url' => [ 37 | 'required', 38 | 'string', 39 | 'url', 40 | // verify that domain of entered url 41 | // has any A, AAAA or CNAME record 42 | // and a TXT record with the users token 43 | DnsRecordExists::make() 44 | ->expect(DNS_A|DNS_AAAA|DNS_CNAME) 45 | ->expect(DNS_TXT, fn(TXT $record): bool => $record->txt() === 'token='.$this->user()->public_token), 46 | ], 47 | 'email' => [ 48 | 'required', 49 | 'string', 50 | 'email', 51 | // verify that domain of entered email 52 | // has any MX record 53 | // and SPF setup 54 | DnsRecordExists::make() 55 | ->expect(DNS_MX) 56 | ->expect(DNS_TXT, fn(TXT $record): bool => str_starts_with($record->txt(), 'v=spf1 ')), 57 | ], 58 | 'domain' => [ 59 | 'required', 60 | 'string', 61 | // verify that entered domain 62 | // has an A record 63 | // pointing to our IP-address 64 | DnsRecordExists::make() 65 | ->expect(DNS_A, fn(A $record): bool => $record->ip() === '127.0.0.1'), 66 | ], 67 | 'something' => [ 68 | 'required', 69 | 'string', 70 | // verify that value is something with DNS 71 | DnsRecordExists::make(), 72 | ], 73 | ]; 74 | ``` 75 | 76 | ```php 77 | use Astrotomic\Dns\Domain; 78 | 79 | protected $casts = [ 80 | 'domain' => Domain::class, 81 | ]; 82 | ``` 83 | 84 | ```php 85 | use Astrotomic\Dns\Domain; 86 | 87 | /** @var \Astrotomic\Dns\Domain $domain */ 88 | $domain = Domain::make('dns@astrotomic.info'); 89 | 90 | /** @var string|null $domain */ 91 | $domain = Domain::parse('dns@astrotomic.info'); 92 | ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astrotomic/laravel-dns", 3 | "type": "library", 4 | "description": "", 5 | "keywords": [ 6 | "astrotomic", 7 | "laravel-dns", 8 | "laravel", 9 | "dns" 10 | ], 11 | "homepage": "https://astrotomic.info", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Tom Witkowski", 16 | "email": "gummibeer@astrotomic.info", 17 | "homepage": "https://gummibeer.de", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.1", 23 | "illuminate/collections": "^9.0 || ^10.0", 24 | "illuminate/container": "^9.0 || ^10.0", 25 | "illuminate/contracts": "^9.0 || ^10.0", 26 | "illuminate/support": "^9.0 || ^10.0", 27 | "illuminate/translation": "^9.0 || ^10.0", 28 | "spatie/dns": "^2.0.2" 29 | }, 30 | "require-dev": { 31 | "friendsofphp/php-cs-fixer": "^3.0", 32 | "orchestra/testbench": "^7.0 || ^8.0", 33 | "pestphp/pest": "^1.22", 34 | "pestphp/pest-plugin-laravel": "^1.3" 35 | }, 36 | "config": { 37 | "sort-packages": true, 38 | "allow-plugins": { 39 | "pestphp/pest-plugin": true 40 | } 41 | }, 42 | "extra": { 43 | "laravel": { 44 | "providers": [ 45 | "Astrotomic\\Dns\\DnsServiceProvider" 46 | ] 47 | } 48 | }, 49 | "autoload": { 50 | "psr-4": { 51 | "Astrotomic\\Dns\\": "src" 52 | } 53 | }, 54 | "autoload-dev": { 55 | "psr-4": { 56 | "Tests\\": "tests" 57 | } 58 | }, 59 | "scripts": { 60 | "fix": "vendor/bin/php-cs-fixer fix --allow-risky=yes --using-cache=no", 61 | "test": "vendor/bin/pest", 62 | "test-coverage": "XDEBUG_MODE=coverage vendor/bin/pest --coverage" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | 14 | ./src 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Casts/DomainCast.php: -------------------------------------------------------------------------------- 1 | getRecords($search, $types)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/DnsServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(Types::class); 16 | 17 | $this->app->singleton(Factory::class); 18 | 19 | $this->app->singleton(Dns::class, static function (Container $app): Dns { 20 | return new Dns( 21 | $app->make(Types::class), 22 | $app->make(Factory::class) 23 | ); 24 | }); 25 | $this->app->alias(Dns::class, SpatieDns::class); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Domain.php: -------------------------------------------------------------------------------- 1 | domain; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Facades/Dns.php: -------------------------------------------------------------------------------- 1 | */ 15 | protected array $expectations = []; 16 | 17 | public static function make(): static 18 | { 19 | return Container::getInstance()->make(static::class); 20 | } 21 | 22 | public function __construct(protected Dns $dns) 23 | { 24 | } 25 | 26 | public function expect(int | string $type, ?Closure $expectation = null): static 27 | { 28 | $this->expectations[$type] = $expectation ?? fn () => true; 29 | 30 | return $this; 31 | } 32 | 33 | public function passes($attribute, $value): bool 34 | { 35 | if (! is_string($value)) { 36 | return false; 37 | } 38 | 39 | $domain = Domain::parse($value); 40 | 41 | if ($domain === null) { 42 | return false; 43 | } 44 | 45 | if (empty($this->expectations)) { 46 | return $this->dns->records($domain)->isNotEmpty(); 47 | } 48 | 49 | return collect($this->expectations) 50 | ->every( 51 | fn (Closure $expectation, int | string $type): bool => $this->dns->records($domain, $type) 52 | ->filter($expectation) 53 | ->isNotEmpty() 54 | ); 55 | } 56 | 57 | public function message(): string 58 | { 59 | return Lang::get('validation.dns'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/DnsRecordExistsTest.php: -------------------------------------------------------------------------------- 1 | passes('', 'astrotomic.info') 10 | )->toBeTrue(); 11 | }); 12 | 13 | it('validates that URL is reachable', function () { 14 | expect( 15 | DnsRecordExists::make() 16 | ->expect(DNS_A | DNS_AAAA | DNS_CNAME) 17 | ->passes('', 'https://astrotomic.info') 18 | )->toBeTrue(); 19 | }); 20 | 21 | it('validates that address is mailable', function () { 22 | expect( 23 | DnsRecordExists::make() 24 | ->expect(DNS_MX) 25 | ->expect(DNS_TXT, fn (TXT $record): bool => str_starts_with($record->txt(), 'v=spf1 ')) 26 | ->passes('', 'dns@astrotomic.info') 27 | )->toBeTrue(); 28 | }); 29 | 30 | it('fails when domain does not exist', function () { 31 | expect( 32 | DnsRecordExists::make() 33 | ->passes('', 'foo.astrotomic') 34 | )->toBeFalse(); 35 | }); 36 | 37 | it('fails when record type is not present', function () { 38 | expect( 39 | DnsRecordExists::make() 40 | ->expect(DNS_CNAME) 41 | ->passes('', 'astrotomic.info') 42 | )->toBeFalse(); 43 | }); 44 | 45 | it('fails when expectation is not fulfilled', function () { 46 | expect( 47 | DnsRecordExists::make() 48 | ->expect(DNS_ALL, fn () => false) 49 | ->passes('', 'astrotomic.info') 50 | )->toBeFalse(); 51 | }); 52 | 53 | it('fails when value is not a string', function () { 54 | expect( 55 | DnsRecordExists::make()->passes('', ['astrotomic.info']) 56 | )->toBeFalse(); 57 | }); 58 | 59 | it('fails when domain is empty', function () { 60 | expect( 61 | DnsRecordExists::make()->passes('', '') 62 | )->toBeFalse(); 63 | }); 64 | -------------------------------------------------------------------------------- /tests/DnsTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(Collection::class) 10 | ->each->toBeInstanceOf(Record::class); 11 | }); 12 | -------------------------------------------------------------------------------- /tests/DomainCastTest.php: -------------------------------------------------------------------------------- 1 | $domain])->domain)->toBeNull(); 9 | })->with([ 10 | 'null' => null, 11 | 'empty' => '', 12 | ]); 13 | 14 | it('it casts raw value to domain instance', function ($domain) { 15 | expect(Team::new(['domain' => $domain])->domain) 16 | ->toBeInstanceOf(Domain::class) 17 | ->toEqual('astrotomic.info'); 18 | })->with([ 19 | 'string' => 'https://astrotomic.info', 20 | 'stringable' => Str::of('https://astrotomic.info'), 21 | 'domain' => Domain::make('https://astrotomic.info'), 22 | ]); 23 | 24 | it('it casts empty value to null', function ($domain) { 25 | $team = Team::new(); 26 | $team->domain = $domain; 27 | expect($team->getAttributes()['domain'])->toBeNull(); 28 | })->with([ 29 | 'null' => null, 30 | 'empty' => '', 31 | ]); 32 | 33 | it('it casts value to sanitized string', function ($domain) { 34 | $team = Team::new(); 35 | $team->domain = $domain; 36 | expect($team->getAttributes()['domain']) 37 | ->toBeString() 38 | ->toEqual('astrotomic.info'); 39 | })->with([ 40 | 'string' => 'https://astrotomic.info', 41 | 'stringable' => Str::of('https://astrotomic.info'), 42 | 'domain' => Domain::make('https://astrotomic.info'), 43 | ]); 44 | -------------------------------------------------------------------------------- /tests/DomainTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(Domain::class) 10 | ->toEqual('astrotomic.info'); 11 | }); 12 | 13 | it('parses domain', function ($domain) { 14 | expect(Domain::parse($domain)) 15 | ->toBeString() 16 | ->toEqual('astrotomic.info'); 17 | })->with([ 18 | 'string' => 'https://astrotomic.info', 19 | 'stringable' => Str::of('https://astrotomic.info'), 20 | 'domain' => Domain::make('https://astrotomic.info'), 21 | ]); 22 | 23 | it('can parse from empty', function ($domain) { 24 | expect(Domain::parse($domain))->toBeNull(); 25 | })->with([ 26 | 'null' => null, 27 | 'empty' => '', 28 | ]); 29 | 30 | it('is JSON serializable', function () { 31 | expect(json_encode(Domain::make('https://astrotomic.info'))) 32 | ->toBeString() 33 | ->toEqual('"astrotomic.info"'); 34 | }); 35 | 36 | it('throws exception for invalid domain', function () { 37 | Domain::make(''); 38 | })->expectException(InvalidArgument::class); 39 | -------------------------------------------------------------------------------- /tests/Models/Team.php: -------------------------------------------------------------------------------- 1 | Domain::class, 17 | ]; 18 | 19 | public static function new(array $attributes = []): self 20 | { 21 | return tap(new static(), fn (Team $team) => $team->setRawAttributes($attributes)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in(__DIR__); 6 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |