From c3a21eceebe4a9185d45af814afc4dadad2582ef Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:19:57 +0100 Subject: [PATCH] fix(dns): replace unused Coolify records and show conflicts on automatic create Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ app/Events/DnsRecordConfigurationFinished.php | 3 + app/Exceptions/DnsRecordConflictException.php | 1 + app/Jobs/ConfigureDnsRecordJob.php | 57 ++++++++++++++++++- .../Concerns/InteractsWithDnsProviders.php | 20 ++++++- app/Services/Dns/CloudflareDnsProvider.php | 2 +- .../shared/dns-provider-management.blade.php | 6 +- 7 files changed, 84 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5783668d1d..db28628def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Fixed + +- 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" + ## [v4.6.0] - 2026-11-02 ### Breaking Changes diff --git a/app/Events/DnsRecordConfigurationFinished.php b/app/Events/DnsRecordConfigurationFinished.php index bb0259c439..f5aee623bd 100644 --- a/app/Events/DnsRecordConfigurationFinished.php +++ b/app/Events/DnsRecordConfigurationFinished.php @@ -19,6 +19,8 @@ class DnsRecordConfigurationFinished implements ShouldBroadcastNow public bool $successful, public string $credential, public string $message, + /** @var array{zone_id: int, record_id: string, current: string, proposed: string}|null The record that blocked the create. */ + public ?array $conflict = null, ) {} public function broadcastOn(): array @@ -41,6 +43,7 @@ class DnsRecordConfigurationFinished implements ShouldBroadcastNow 'successful' => $this->successful, 'credential' => $this->credential, 'message' => $this->message, + 'conflict' => $this->conflict, ])]; } } diff --git a/app/Exceptions/DnsRecordConflictException.php b/app/Exceptions/DnsRecordConflictException.php index 00f5c73b72..cf1470f8a9 100644 --- a/app/Exceptions/DnsRecordConflictException.php +++ b/app/Exceptions/DnsRecordConflictException.php @@ -10,6 +10,7 @@ class DnsRecordConflictException extends RuntimeException public readonly string $providerRecordId, public readonly string $currentValue, public readonly string $proposedValue, + public readonly ?string $currentComment = null, ) { parent::__construct("DNS record already points to {$currentValue}."); } diff --git a/app/Jobs/ConfigureDnsRecordJob.php b/app/Jobs/ConfigureDnsRecordJob.php index 7430e45807..dada9bc87c 100644 --- a/app/Jobs/ConfigureDnsRecordJob.php +++ b/app/Jobs/ConfigureDnsRecordJob.php @@ -3,8 +3,12 @@ namespace App\Jobs; use App\Events\DnsRecordConfigurationFinished; +use App\Exceptions\DnsRecordConflictException; use App\Models\DnsProviderZone; +use App\Models\ManagedDnsRecord; +use App\Models\ManagedDnsRecordReference; use App\Services\Dns\CloudflareDnsProvider; +use App\Services\Dns\ManagedDnsRecordCleanup; use App\Support\DnsRecordHints; use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Contracts\Queue\ShouldQueue; @@ -34,6 +38,10 @@ class ConfigureDnsRecordJob implements ShouldQueue public string $content, ) {} + /** + * A record with another value blocks the create. Coolify replaces it only when it is a record that Coolify created + * and that no other resource uses. Otherwise the conflict goes to the UI, where the user can replace it. + */ public function handle(CloudflareDnsProvider $provider): void { $zone = DnsProviderZone::query() @@ -48,8 +56,24 @@ class ConfigureDnsRecordJob implements ShouldQueue return; } + $resource = $this->resource(); try { - $provider->createRecord($zone, $this->hostname, $this->content, $this->resource()); + try { + $provider->createRecord($zone, $this->hostname, $this->content, $resource); + $message = "DNS record added for {$this->hostname}."; + } catch (DnsRecordConflictException $conflict) { + if (! $this->isUnusedCoolifyRecord($zone, $conflict, $resource)) { + $this->finished($zone, false, $conflict->getMessage(), [ + 'zone_id' => $zone->id, 'record_id' => $conflict->providerRecordId, + 'current' => $conflict->currentValue, 'proposed' => $conflict->proposedValue, + ]); + + return; + } + + $provider->replaceRecord($zone, $conflict->providerRecordId, $this->hostname, $this->content, $resource, $conflict->currentValue); + $message = "DNS record replaced for {$this->hostname}. It pointed to {$conflict->currentValue}, which no other resource uses."; + } } catch (LockTimeoutException) { if ($this->attempts() < $this->tries) { $this->release(10 * $this->attempts()); @@ -66,10 +90,36 @@ class ConfigureDnsRecordJob implements ShouldQueue return; } - $this->finished($zone, true, "DNS record added for {$this->hostname}."); + $this->finished($zone, true, $message); } - private function finished(DnsProviderZone $zone, bool $successful, string $message): void + /** + * True when Coolify created the record (owned, and the provider comment still proves it) and no resource other + * than this one references the record or uses the hostname. + */ + private function isUnusedCoolifyRecord(DnsProviderZone $zone, DnsRecordConflictException $conflict, ?Model $resource): bool + { + if ($resource === null) { + return false; + } + + $record = ManagedDnsRecord::query()->with('references.resource') + ->where('dns_provider_zone_id', $zone->id) + ->where('provider_record_id', $conflict->providerRecordId) + ->first(); + if ($record === null || ! $record->owned || $conflict->currentComment !== $record->ownershipComment()) { + return false; + } + + $resourceKey = $resource->getMorphClass().'|'.$resource->getKey(); + $usedByOtherResource = $record->references->contains(fn (ManagedDnsRecordReference $reference): bool => $reference->resource !== null + && $reference->resource_type.'|'.$reference->resource_id !== $resourceKey); + + return ! $usedByOtherResource && app(ManagedDnsRecordCleanup::class)->resourcesUsingHostname($this->hostname, [$resourceKey])->isEmpty(); + } + + /** @param array{zone_id: int, record_id: string, current: string, proposed: string}|null $conflict */ + private function finished(DnsProviderZone $zone, bool $successful, string $message, ?array $conflict = null): void { DnsRecordConfigurationFinished::dispatch( $this->teamId, @@ -79,6 +129,7 @@ class ConfigureDnsRecordJob implements ShouldQueue $successful, $zone->integrationToken->name, $message, + $conflict, ); } diff --git a/app/Livewire/Concerns/InteractsWithDnsProviders.php b/app/Livewire/Concerns/InteractsWithDnsProviders.php index cc58fa51fe..c9022fb03a 100644 --- a/app/Livewire/Concerns/InteractsWithDnsProviders.php +++ b/app/Livewire/Concerns/InteractsWithDnsProviders.php @@ -229,12 +229,17 @@ trait InteractsWithDnsProviders } protected function markDnsManaged(string $hostname, string $credential): void + { + $this->markDnsStatus($hostname, 'ok', "DNS record created through {$credential}."); + } + + private function markDnsStatus(string $hostname, string $status, string $message): void { foreach ($this->domainRows as $index => $row) { $rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST); if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) { - $this->domainRows[$index]['dns_status'] = 'ok'; - $this->domainRows[$index]['dns_message'] = "DNS record created through {$credential}."; + $this->domainRows[$index]['dns_status'] = $status; + $this->domainRows[$index]['dns_message'] = $message; $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); } } @@ -272,6 +277,17 @@ trait InteractsWithDnsProviders return; } + $conflict = $event['conflict'] ?? null; + if (is_array($conflict) && is_numeric($conflict['zone_id'] ?? null) && is_string($conflict['record_id'] ?? null) + && is_string($conflict['current'] ?? null) && is_string($conflict['proposed'] ?? null)) { + $this->dnsProviderConflicts[strtolower($event['hostname']).'|'.(int) $conflict['zone_id']] = [ + 'record_id' => $conflict['record_id'], 'current' => $conflict['current'], 'proposed' => $conflict['proposed'], + ]; + $this->loadDnsProviderProposals(); + $this->showDnsProviderModal = true; + } + + $this->markDnsStatus($event['hostname'], 'failed', $event['message']); $this->dispatch('error', "DNS record could not be added for {$event['hostname']}: {$event['message']}"); } diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php index 85cfb7fece..89402f0684 100644 --- a/app/Services/Dns/CloudflareDnsProvider.php +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -181,7 +181,7 @@ class CloudflareDnsProvider throw new RuntimeException("Several DNS records already exist for {$hostname}. Update them in Cloudflare."); } if (count($remoteRecords) === 1) { - throw new DnsRecordConflictException($remoteRecords[0]['id'], $remoteRecords[0]['content'], $content); + throw new DnsRecordConflictException($remoteRecords[0]['id'], $remoteRecords[0]['content'], $content, $remoteRecords[0]['comment']); } $uuid = new_public_id(); diff --git a/resources/views/livewire/project/shared/dns-provider-management.blade.php b/resources/views/livewire/project/shared/dns-provider-management.blade.php index 58d42a05e8..f8bbd0073d 100644 --- a/resources/views/livewire/project/shared/dns-provider-management.blade.php +++ b/resources/views/livewire/project/shared/dns-provider-management.blade.php @@ -25,9 +25,7 @@
{{ $proposal['hostname'] }}
{{ $proposal['credential'] }} · {{ $proposal['zone'] }}@if (filled($proposal['target'])) · {{ $proposal['target'] }}@endif
- @if ($proposal['managed']) - - @elseif (isset($dnsProviderConflicts[$key])) + @if (isset($dnsProviderConflicts[$key])) @php($conflict = $dnsProviderConflicts[$key])
Currently {{ $conflict['current'] }}
+ @elseif ($proposal['managed']) + @elseif (blank($proposal['target'])) Add manually @else