fix(dns): check the closest Cloudflare wildcard for deeper domain names

Closes #12436

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-11-03 19:42:14 +01:00
1 parent d70801aaa1
commit e20e652c00
5 files changed
+52 -14

No files matched your search

+1 -1
View File
@@ -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
+6 -3
View File
@@ -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) {
@@ -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([
+9 -4
View File
@@ -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'],
]);
@@ -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');