fix(dns): verify the Cloudflare origin and add records for clones and API-created resources

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-03 18:17:53 +01:00
co-authored by Claude Opus 5.5
parent 7ec000ab86
commit b7defddc32
11 changed files with 255 additions and 16 deletions
+2
View File
@@ -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**
+36 -10
View File
@@ -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<int, string> $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<int, string>
*/
@@ -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) {
@@ -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);
}
+48 -2
View File
@@ -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<int, array{id: string, type: string, name: string, content: string, proxied: bool|null, ttl: int|null, comment: string|null}>
*/
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.
*
+4
View File
@@ -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;
}
+4
View File
@@ -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;
}
+4 -3
View File
@@ -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;
}
}
+70
View File
@@ -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]]);
+77
View File
@@ -1,9 +1,13 @@
<?php
use App\Actions\Shared\CheckDomainDns;
use App\Models\DnsProviderZone;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use PurplePixie\PhpDns\DNSQuery;
use PurplePixie\PhpDns\DNSResult;
use PurplePixie\PhpDns\DNSTypes;
@@ -307,3 +311,76 @@ 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) {
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');
});
+1 -1
View File
@@ -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]);
}