diff --git a/CHANGELOG.md b/CHANGELOG.md index 057fdd716c..e577e77183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ 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)) +- 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 the closest wildcard record that covers it (also for deeper names, such as `app.apps.example.com` through `*.example.com`), 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 diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php index 28051f0a46..07f5039a26 100644 --- a/app/Services/Dns/CloudflareDnsProvider.php +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -119,7 +119,8 @@ class CloudflareDnsProvider /** * 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. + * the origin, so the zone's records are read: the hostname's own record, or else the closest wildcard record that + * covers it (a Cloudflare wildcard also covers deeper names, so `*.example.com` covers `app.apps.example.com`). * 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. */ @@ -136,10 +137,12 @@ class CloudflareDnsProvider } $names = [$hostname]; - $parent = str($hostname)->after('.')->value(); - if ($parent === $zone->name || str_ends_with($parent, '.'.$zone->name)) { + for ($parent = str($hostname)->after('.')->value(); str_ends_with($parent, '.'.$zone->name); $parent = str($parent)->after('.')->value()) { $names[] = '*.'.$parent; } + if (str_ends_with($hostname, '.'.$zone->name)) { + $names[] = '*.'.$zone->name; + } try { foreach ($names as $name) { diff --git a/tests/Feature/DnsProviderManagementTest.php b/tests/Feature/DnsProviderManagementTest.php index 2d5a75858d..1eecd2fa0d 100644 --- a/tests/Feature/DnsProviderManagementTest.php +++ b/tests/Feature/DnsProviderManagementTest.php @@ -486,6 +486,40 @@ describe('domain DNS configuration after add', function () { Queue::assertNotPushed(CheckDomainDnsJob::class); }); + test('service domains queues a dns record job when a generated domain is edited to a managed zone', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + $webApp = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'service_id' => $service->id, + 'name' => 'web', + 'human_name' => 'Web', + 'image' => 'nginx:alpine', + 'fqdn' => 'https://web-generated.203.0.113.10.sslip.io', + ]); + + Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])]) + ->call('startEdit', 0) + ->set('editingDomainParts.host', 'app.example.com') + ->call('updateDomain') + ->assertHasNoErrors() + ->assertDispatched('success', 'Domain updated.') + ->assertDispatched('info', 'Adding DNS record for app.example.com.'); + + expect($webApp->fresh()->fqdn)->toBe('https://app.example.com'); + Queue::assertPushed(ConfigureDnsRecordJob::class, 1); + Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'app.example.com' + && $job->content === '203.0.113.10' + && $job->zoneId === $this->zone->id + && (string) $job->resourceId === (string) $webApp->id); + Queue::assertNotPushed(CheckDomainDnsJob::class); + }); + test('private server addresses are never queued as dns records', function (string $address) { $this->server->update(['ip' => $address]); $application = Application::factory()->create([ diff --git a/tests/Feature/DnsValidationTest.php b/tests/Feature/DnsValidationTest.php index db00728a8f..60e35439ac 100644 --- a/tests/Feature/DnsValidationTest.php +++ b/tests/Feature/DnsValidationTest.php @@ -312,7 +312,7 @@ it('passes bracketed IPv6 custom dns servers to the dns query', function () { expect($queriedServers->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) { +it('checks the Cloudflare record origin when a proxied hostname is in a zone of the team', function (array $records, string $status, string $hostname = 'app.example.com') { InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( ['id' => 0], ['is_dns_validation_enabled' => true, 'custom_dns_servers' => '192.0.2.1'] @@ -337,12 +337,12 @@ it('checks the Cloudflare record origin when a proxied hostname is in a zone of $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'); + $result = CheckDomainDns::run(['app' => "https://{$hostname}"], $server, '203.0.113.10'); expect($result['app']['status'])->toBe($status) - ->and(validateDNSEntry('https://app.example.com', $server))->toBe($status === 'ok'); + ->and(validateDNSEntry("https://{$hostname}", $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'); + expect($result['app']['message'])->toBe("Cloudflare sends {$hostname} 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'], @@ -353,6 +353,11 @@ it('checks the Cloudflare record origin when a proxied hostname is in a zone of ['type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10'], ['type' => 'A', 'name' => '*.example.com', 'content' => '198.51.100.20'], ], 'ok'], + 'wildcard of the zone covers a deeper hostname' => [[['type' => 'A', 'name' => '*.example.com', 'content' => '198.51.100.20']], 'failed', 'app.apps.example.com'], + 'closest wildcard wins for a deeper hostname' => [[ + ['type' => 'A', 'name' => '*.apps.example.com', 'content' => '203.0.113.10'], + ['type' => 'A', 'name' => '*.example.com', 'content' => '198.51.100.20'], + ], 'ok', 'app.apps.example.com'], 'CNAME origin cannot be verified' => [[['type' => 'CNAME', 'name' => 'app.example.com', 'content' => 'tunnel.cfargotunnel.com']], 'ok'], 'no record found' => [[], 'ok'], ]); diff --git a/tests/Feature/ManagedDnsRecordEditReleaseTest.php b/tests/Feature/ManagedDnsRecordEditReleaseTest.php index d4164fecc6..5412fd6b89 100644 --- a/tests/Feature/ManagedDnsRecordEditReleaseTest.php +++ b/tests/Feature/ManagedDnsRecordEditReleaseTest.php @@ -39,7 +39,8 @@ uses(RefreshDatabase::class); beforeEach(function () { $this->withoutVite(); - Queue::fake([CheckDomainDnsJob::class]); + // A new hostname of an automatic DNS zone also queues a record; DnsProviderManagementTest and DnsApiTest cover that. + Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); config()->set('app.maintenance.store', 'array'); config()->set('app.id', 'test-instance'); @@ -315,11 +316,6 @@ describe('UI service domain edit', function () { }); describe('API domain edit', function () { - beforeEach(function () { - // A new domain of an automatic DNS zone also queues a record; DnsApiTest covers that. - Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); - }); - test('an application domain update releases the removed hostname', function () { $cloudflare = fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://app.example.com,https://keep.example.com');