From b7defddc32000f552ed215e42282e3c516e2ce05 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:17:53 +0100 Subject: [PATCH] fix(dns): verify the Cloudflare origin and add records for clones and API-created resources Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + app/Actions/Shared/CheckDomainDns.php | 46 ++++++++--- .../Api/ApplicationsController.php | 5 ++ .../Controllers/Api/ServicesController.php | 4 + app/Services/Dns/CloudflareDnsProvider.php | 50 +++++++++++- bootstrap/helpers/applications.php | 4 + bootstrap/helpers/services.php | 4 + bootstrap/helpers/shared.php | 7 +- tests/Feature/DnsApiTest.php | 70 +++++++++++++++++ tests/Feature/DnsValidationTest.php | 77 +++++++++++++++++++ tests/Support/ManagedDnsTestHelpers.php | 2 +- 11 files changed, 255 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8955c24f..5d36a19140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Do not copy the preview deployments of an application when you clone it. A clone of an application with a fixed preview URL template (for example `demo-mr-{{pr_id}}.example.com`) failed with a duplicate preview domain error and left a stopped, partial clone. The clone gets its own previews from new pull requests - Handle an existing Cloudflare DNS record with a different IP when Coolify adds the DNS record of a new domain automatically. Coolify now replaces the record when it created the record and no other resource uses it. Otherwise it opens the **Configure DNS** dialog, where you can replace the record, and the domain shows the conflict instead of "Not checked" - Add the Cloudflare DNS record automatically, or open the **Configure DNS** dialog, also when you edit a domain to a hostname in a connected Cloudflare zone, for example when you change a generated domain to your own domain. Before, Coolify did this only when you added a new domain. This applies to the **Domains** pages, the application and service application settings, and the service application API +- Show "DNS mismatch" when a domain in a connected Cloudflare zone goes through Cloudflare to a different server. Before, the DNS check accepted every Cloudflare IP, so a proxied wildcard record that points to another server showed "DNS matches", and the domain then failed with Cloudflare error 526 and a Let's Encrypt error. Coolify now reads the Cloudflare record of the domain, or else its wildcard record, and compares the IP with the server. CNAME records, for example of a Cloudflare Tunnel, are still accepted ([#12436](https://github.com/coollabsio/coolify/issues/12436)) +- Add the Cloudflare DNS record automatically when you clone an application or a service, and when you create an application or a service through the API, for each domain in a Cloudflare zone with automatic DNS. A clone gets records only for the domains that the source does not use, so the record of the source stays as it is ([#12436](https://github.com/coollabsio/coolify/issues/12436)) - Save deployments started through the MCP server as MCP deployments, not as API deployments. The deployment list shows them with the **MCP** source and filter, and the API and MCP deployment data have a new `is_mcp` field. The deployments to additional servers keep the source of the main deployment, and the audit log records actions that MCP tools do through the API as MCP events - Save the update channel in **Settings > Updates** when you select **Preview** or **Nightly**. Before, the "Save changes" bar did not open for these channels, so the change was lost when you refreshed the page - Save the healthcheck **Check type** when it is the only change. Before, the "Save changes" bar did not open when you changed only the check type, for example from **Container command** back to **HTTP request** diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php index de56a4e33b..627e0de9cb 100644 --- a/app/Actions/Shared/CheckDomainDns.php +++ b/app/Actions/Shared/CheckDomainDns.php @@ -3,6 +3,7 @@ namespace App\Actions\Shared; use App\Models\Server; +use App\Services\Dns\CloudflareDnsProvider; use App\Support\DnsRecordHints; use Lorisleiva\Actions\Concerns\AsAction; use PurplePixie\PhpDns\DNSTypes; @@ -81,15 +82,20 @@ class CheckDomainDns continue; } + $addresses = []; foreach ($records as $record) { - if ($record->getType() !== $type) { - continue; + if ($record->getType() === $type) { + $addresses[] = $record->getData(); } + } + if ($addresses === []) { + continue; + } - $receivedAddressRecord = true; - if (isCloudflareIp($record->getData()) || ($expectedIp && DnsRecordHints::sameAddress($record->getData(), $expectedIp))) { - return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp); - } + $receivedAddressRecord = true; + $result = $this->resultForAddresses($addresses, $host, $server, $expectedIp, $deadline); + if ($result !== null) { + return $result; } } catch (\Throwable) { continue; @@ -97,16 +103,36 @@ class CheckDomainDns } if (! $receivedAddressRecord && hrtime(true) < $deadline) { - foreach ($this->resolveWithSystemDns($host, $type) as $resolvedIp) { - if (isCloudflareIp($resolvedIp) || ($expectedIp && DnsRecordHints::sameAddress($resolvedIp, $expectedIp))) { - return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp); - } + $result = $this->resultForAddresses($this->resolveWithSystemDns($host, $type), $host, $server, $expectedIp, $deadline); + if ($result !== null) { + return $result; } } return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp); } + /** + * The result for the addresses that DNS returned for the host, or null when none of them matches. + * A Cloudflare IP matches unless the Cloudflare records of the team's zone send the host to another server. + * + * @param array $addresses + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string}|null + */ + private function resultForAddresses(array $addresses, string $host, Server $server, ?string $expectedIp, int $deadline): ?array + { + $matches = collect($addresses)->contains(fn (string $address): bool => $expectedIp && DnsRecordHints::sameAddress($address, $expectedIp)); + if (! $matches && collect($addresses)->contains(fn (string $address): bool => isCloudflareIp($address))) { + $timeoutSeconds = max(1, intdiv($deadline - hrtime(true), 1_000_000_000)); + if (app(CloudflareDnsProvider::class)->sendsHostnameToAnotherServer($server, $host, $expectedIp, $timeoutSeconds)) { + return $this->result('failed', "Cloudflare sends {$host} to another server. ".dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp); + } + $matches = true; + } + + return $matches ? $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp) : null; + } + /** * @return array */ diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 0e3e62b015..31b74d6c76 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1768,6 +1768,7 @@ class ApplicationsController extends Controller if ($tagNames !== []) { $this->attachTagsToResource($application, $tagNames, $teamId); } + app(ResourceDnsRecords::class)->queueAutomaticRecords($application, []); $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -2031,6 +2032,7 @@ class ApplicationsController extends Controller if ($tagNames !== []) { $this->attachTagsToResource($application, $tagNames, $teamId); } + app(ResourceDnsRecords::class)->queueAutomaticRecords($application, []); $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -2263,6 +2265,7 @@ class ApplicationsController extends Controller if ($tagNames !== []) { $this->attachTagsToResource($application, $tagNames, $teamId); } + app(ResourceDnsRecords::class)->queueAutomaticRecords($application, []); $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -2402,6 +2405,7 @@ class ApplicationsController extends Controller if ($tagNames !== []) { $this->attachTagsToResource($application, $tagNames, $teamId); } + app(ResourceDnsRecords::class)->queueAutomaticRecords($application, []); $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -2537,6 +2541,7 @@ class ApplicationsController extends Controller if ($tagNames !== []) { $this->attachTagsToResource($application, $tagNames, $teamId); } + app(ResourceDnsRecords::class)->queueAutomaticRecords($application, []); $application->isConfigurationChanged(true); if ($instantDeploy) { diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 7fa1eb34da..c939964cdf 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -611,6 +611,8 @@ class ServicesController extends Controller $this->attachTagsToResource($service, $request->tags, $teamId); } + app(ResourceDnsRecords::class)->queueAutomaticRecords($service, []); + if ($instantDeploy) { StartService::dispatch($service); } @@ -799,6 +801,8 @@ class ServicesController extends Controller $this->attachTagsToResource($service, $request->tags, $teamId); } + app(ResourceDnsRecords::class)->queueAutomaticRecords($service, []); + if ($instantDeploy) { StartService::dispatch($service); } diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php index 89402f0684..28051f0a46 100644 --- a/app/Services/Dns/CloudflareDnsProvider.php +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -14,6 +14,7 @@ use App\Models\ServiceApplication; use App\Support\DnsRecordHints; use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -95,10 +96,10 @@ class CloudflareDnsProvider * * @return array */ - public function findRecords(DnsProviderZone $zone, string $hostname, ?string $type): array + public function findRecords(DnsProviderZone $zone, string $hostname, ?string $type, int $timeoutSeconds = 30): array { $hostname = strtolower(rtrim($hostname, '.')); - $response = $this->client($zone->integrationToken)->get( + $response = $this->client($zone->integrationToken)->timeout($timeoutSeconds)->get( "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", array_filter(['type' => $type, 'name' => $hostname, 'per_page' => 100]), ); @@ -116,6 +117,51 @@ class CloudflareDnsProvider ->all(); } + /** + * Whether Cloudflare sends a proxied hostname to another server than the given one. A Cloudflare IP in DNS hides + * the origin, so the zone's records are read: the hostname's own record, or else the wildcard record that covers it. + * False when Coolify cannot tell: no zone of the server's team manages the hostname, the records cannot be read, + * a CNAME sends the hostname on, or no record is found. + */ + public function sendsHostnameToAnotherServer(Server $server, string $hostname, ?string $expectedIp = null, int $timeoutSeconds = 5): bool + { + $hostname = strtolower(rtrim($hostname, '.')); + $zone = $server->team_id === null ? null : $this->findZones($server->team_id, $hostname)->first(); + if ($zone === null) { + return false; + } + $addresses = array_filter([$expectedIp, ...ServerDnsAddresses::forServer($server)->addresses()]); + if ($addresses === []) { + return false; + } + + $names = [$hostname]; + $parent = str($hostname)->after('.')->value(); + if ($parent === $zone->name || str_ends_with($parent, '.'.$zone->name)) { + $names[] = '*.'.$parent; + } + + try { + foreach ($names as $name) { + $records = collect($this->findRecords($zone, $name, null, $timeoutSeconds)) + ->filter(fn (array $record): bool => in_array($record['type'], ['A', 'AAAA', 'CNAME'], true)); + if ($records->isEmpty()) { + continue; + } + if ($records->contains('type', 'CNAME')) { + return false; + } + + return ! $records->contains(fn (array $record): bool => collect($addresses) + ->contains(fn (string $address): bool => DnsRecordHints::sameAddress($record['content'], $address))); + } + } catch (RuntimeException|ConnectionException) { + return false; + } + + return false; + } + /** * Records that block a new record of the type. A CNAME cannot share its name with A, AAAA, or other CNAME records. * diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 2affdc15dd..908273043f 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -14,6 +14,7 @@ use App\Models\EnvironmentVariable; use App\Models\Server; use App\Models\StandaloneDocker; use App\Notifications\Application\DeploymentFailed; +use App\Services\Dns\ResourceDnsRecords; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -611,6 +612,9 @@ function clone_application(Application $source, $destination, array $overrides = }); } + $dnsRecords = app(ResourceDnsRecords::class); + $dnsRecords->queueAutomaticRecords($newApplication, array_keys($dnsRecords->hostnameUsers($source))); + return $newApplication; } diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 603b4fdc58..50e0b7441d 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -36,6 +36,7 @@ use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use App\Services\Dns\ResourceDnsRecords; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Log; use Illuminate\Support\Stringable; @@ -790,5 +791,8 @@ function clone_service(Service $source, StandaloneDocker|SwarmDocker $destinatio )); } + $dnsRecords = app(ResourceDnsRecords::class); + $dnsRecords->queueAutomaticRecords($newService, array_keys($dnsRecords->hostnameUsers($source))); + return $newService; } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 2dbc3467da..6285ea97d5 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -39,6 +39,7 @@ use App\Models\StandaloneSqlite; use App\Models\SwarmDocker; use App\Models\Team; use App\Models\User; +use App\Services\Dns\CloudflareDnsProvider; use App\Services\Security\SensitiveDataRedactor; use App\Support\DnsRecordHints; use Carbon\CarbonImmutable; @@ -2519,12 +2520,12 @@ function validateDNSEntry(string $fqdn, Server $server) if ($results !== false) { foreach ($results as $result) { if ($result->getType() == $type) { - if (isCloudflareIp($result->getData())) { + if ($ip && DnsRecordHints::sameAddress($result->getData(), $ip)) { $found_matching_ip = true; break 2; } - if ($ip && DnsRecordHints::sameAddress($result->getData(), $ip)) { - $found_matching_ip = true; + if (isCloudflareIp($result->getData())) { + $found_matching_ip = ! app(CloudflareDnsProvider::class)->sendsHostnameToAnotherServer($server, $host, $ip); break 2; } } diff --git a/tests/Feature/DnsApiTest.php b/tests/Feature/DnsApiTest.php index 6a33e1649e..ad7d1e784c 100644 --- a/tests/Feature/DnsApiTest.php +++ b/tests/Feature/DnsApiTest.php @@ -17,6 +17,7 @@ use App\Models\Team; use App\Models\User; use App\Services\Dns\CloudflareDnsProvider; use App\Services\Dns\ManagedDnsHostnameLock; +use App\Services\Dns\ResourceDnsRecords; use App\Support\DnsRecordHints; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Client\ConnectionException; @@ -492,6 +493,75 @@ describe('automatic dns on api domain changes', function () { Queue::assertNotPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'web.example.com'); }); + test('an application created through the api queues a record for its domain', function () { + Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + + $uuid = $this->withToken(dnsApiToken($this->user, $this->team)) + ->postJson('/api/v1/applications/dockerimage', [ + 'project_uuid' => $this->environment->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'docker_registry_image_tag' => 'latest', + 'ports_exposes' => '80', + 'domains' => 'https://api-created.example.com', + 'instant_deploy' => false, + ]) + ->assertCreated() + ->json('uuid'); + + Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'api-created.example.com' + && $job->content === '203.0.113.10' && $job->resourceId === Application::query()->where('uuid', $uuid)->value('id')); + }); + + test('a service created through the api queues a record for its domain', function () { + Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + + $this->withToken(dnsApiToken($this->user, $this->team)) + ->postJson('/api/v1/services', [ + 'project_uuid' => $this->environment->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_compose_raw' => base64_encode("services:\n web:\n image: nginx:alpine\n"), + 'urls' => [['name' => 'web', 'url' => 'https://web-created.example.com']], + 'instant_deploy' => false, + ]) + ->assertCreated(); + + Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'web-created.example.com' + && $job->resourceType === (new ServiceApplication)->getMorphClass()); + }); + + test('a clone queues records only for the domains that the source does not use', function () { + Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); + $this->application->settings()->update(['is_container_label_readonly_enabled' => false]); + $this->application->update(['fqdn' => 'https://app.example.com,https://cloned.example.com']); + $source = $this->application->fresh(); + + $clone = clone_application($source, $this->destination, ['fqdn' => 'https://app.example.com,https://clone-only.example.com']); + + Queue::assertPushed(ConfigureDnsRecordJob::class, 1); + Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'clone-only.example.com' + && $job->resourceId === $clone->id); + }); + + test('a cloned service queues records for its generated domains', function () { + Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); + $this->server->settings()->update(['wildcard_domain' => 'https://example.com']); + $source = dnsApiService($this, 'https://web.example.com')->service; + $source->update(['docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_FQDN_WEB\n"]); + + $clone = clone_service($source->fresh(), $this->destination); + + $cloneHostnames = array_keys(app(ResourceDnsRecords::class)->hostnameUsers($clone)); + expect($cloneHostnames)->not->toBeEmpty()->not->toContain('web.example.com'); + foreach ($cloneHostnames as $hostname) { + Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === $hostname); + } + }); + test('no record is queued when automatic dns is off', function () { Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); $this->token->update(['metadata' => ['automatic_dns' => false]]); diff --git a/tests/Feature/DnsValidationTest.php b/tests/Feature/DnsValidationTest.php index d068c02b19..db00728a8f 100644 --- a/tests/Feature/DnsValidationTest.php +++ b/tests/Feature/DnsValidationTest.php @@ -1,9 +1,13 @@ getArrayCopy())->toBe(['[2606:4700:4700::1111]', '[2001:db8::53]', '192.0.2.1']); }); + +it('checks the Cloudflare record origin when a proxied hostname is in a zone of the team', function (array $records, string $status) { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( + ['id' => 0], + ['is_dns_validation_enabled' => true, 'custom_dns_servers' => '192.0.2.1'] + )); + app()->bind(DNSQuery::class, fn () => new class('192.0.2.1') extends DNSQuery + { + public function query(string $question, string $typeName = DNSTypes::NAME_A) + { + return [new DNSResult($typeName, 1, 'IN', 60, '104.16.0.1', $question, '', [])]; + } + + public function hasError(): bool + { + return false; + } + }); + + $team = Team::factory()->create(); + $token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']); + DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); + fakeCloudflareDns(collect($records)->map(fn (array $record, int $index): array => $record + ['id' => "record-{$index}", 'proxied' => true])->all()); + $server = new Server(['ip' => '203.0.113.10']); + $server->team_id = $team->id; + + $result = CheckDomainDns::run(['app' => 'https://app.example.com'], $server, '203.0.113.10'); + + expect($result['app']['status'])->toBe($status) + ->and(validateDNSEntry('https://app.example.com', $server))->toBe($status === 'ok'); + if ($status === 'failed') { + expect($result['app']['message'])->toBe('Cloudflare sends app.example.com to another server. Required DNS record type A pointing to 203.0.113.10'); + } +})->with([ + 'record points to the server' => [[['type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10']], 'ok'], + 'record points to another server' => [[['type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.20']], 'failed'], + 'wildcard points to another server' => [[['type' => 'A', 'name' => '*.example.com', 'content' => '198.51.100.20']], 'failed'], + 'wildcard points to the server' => [[['type' => 'A', 'name' => '*.example.com', 'content' => '203.0.113.10']], 'ok'], + 'record wins over the wildcard' => [[ + ['type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10'], + ['type' => 'A', 'name' => '*.example.com', 'content' => '198.51.100.20'], + ], 'ok'], + 'CNAME origin cannot be verified' => [[['type' => 'CNAME', 'name' => 'app.example.com', 'content' => 'tunnel.cfargotunnel.com']], 'ok'], + 'no record found' => [[], 'ok'], +]); + +it('keeps a Cloudflare IP as a match when the Cloudflare records cannot be read', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( + ['id' => 0], + ['is_dns_validation_enabled' => true, 'custom_dns_servers' => '192.0.2.1'] + )); + app()->bind(DNSQuery::class, fn () => new class('192.0.2.1') extends DNSQuery + { + public function query(string $question, string $typeName = DNSTypes::NAME_A) + { + return [new DNSResult($typeName, 1, 'IN', 60, '104.16.0.1', $question, '', [])]; + } + + public function hasError(): bool + { + return false; + } + }); + + $team = Team::factory()->create(); + $token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']); + DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); + Http::fake(['api.cloudflare.com/*' => Http::response(['success' => false], 403)]); + $server = new Server(['ip' => '203.0.113.10']); + $server->team_id = $team->id; + + expect(CheckDomainDns::run(['app' => 'https://app.example.com'], $server, '203.0.113.10')['app']['status'])->toBe('ok'); +}); diff --git a/tests/Support/ManagedDnsTestHelpers.php b/tests/Support/ManagedDnsTestHelpers.php index af9aa6441d..d19d20ced1 100644 --- a/tests/Support/ManagedDnsTestHelpers.php +++ b/tests/Support/ManagedDnsTestHelpers.php @@ -27,7 +27,7 @@ function fakeCloudflareDns(array $records = []): ArrayObject if ($id === null && $request->method() === 'GET') { parse_str($matches[2] ?? '', $query); $result = collect($records)->filter(fn (array $record): bool => $record['name'] === ($query['name'] ?? null) - && $record['type'] === ($query['type'] ?? null))->values()->all(); + && (! isset($query['type']) || $record['type'] === $query['type']))->values()->all(); return Http::response(['success' => true, 'result' => $result]); }