From 0ab19998d56d4efa5252eb89febcc138519d883f Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 30 Sep 2026 19:23:35 +0200
Subject: [PATCH] fix(dns): send only a public server IP to Cloudflare Domain
Connect
Domain Connect used the unfiltered server IPs, so a private address could be
published. It now uses the first public IP and refuses with the no public IP
message when there is none, like automatic DNS.
---
.../InteractsWithCloudflareDomainConnect.php | 29 +++++++
.../shared/cloudflare-autoconfigure.blade.php | 2 +-
tests/Feature/ApplicationDomainsTest.php | 78 +++++++++++++++++++
3 files changed, 108 insertions(+), 1 deletion(-)
diff --git a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php
index 6ecea8e96f..f69fca6770 100644
--- a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php
+++ b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php
@@ -33,6 +33,12 @@ trait InteractsWithCloudflareDomainConnect
return;
}
+ if ($this->publicServerIpForDomainConnect() === null) {
+ $this->dispatch('error', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE);
+
+ return;
+ }
+
$this->showCloudflareAutoconfigureModal = true;
}
@@ -66,6 +72,14 @@ trait InteractsWithCloudflareDomainConnect
return;
}
+ $ip = $this->publicServerIpForDomainConnect();
+ if ($ip === null) {
+ $this->showCloudflareAutoconfigureModal = false;
+ $this->dispatch('error', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE);
+
+ return;
+ }
+
$hostnames = $this->allDomainHostnames();
if ($hostnames === []) {
$this->dispatch('error', 'Add at least one domain before configuring DNS on Cloudflare.');
@@ -245,6 +259,21 @@ trait InteractsWithCloudflareDomainConnect
return rtrim($hostname, '.');
}
+ /**
+ * Address that Domain Connect may publish at Cloudflare: the first public server address (IPv4 first).
+ * Private, reserved, CGNAT, and link-local addresses are never sent to Cloudflare.
+ */
+ protected function publicServerIpForDomainConnect(): ?string
+ {
+ foreach ($this->serverIpsForDnsHints() as $address) {
+ if (is_string($address) && DnsRecordHints::isPublicAddress($address)) {
+ return $address;
+ }
+ }
+
+ return null;
+ }
+
protected function serverIpForDomainConnect(): ?string
{
if (filled($this->serverIp) && filter_var($this->serverIp, FILTER_VALIDATE_IP) !== false) {
diff --git a/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php b/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php
index 626a019862..8d102ff47f 100644
--- a/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php
+++ b/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php
@@ -82,7 +82,7 @@
diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php
index 434233e13f..ca1e115f24 100644
--- a/tests/Feature/ApplicationDomainsTest.php
+++ b/tests/Feature/ApplicationDomainsTest.php
@@ -17,6 +17,7 @@ use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
+use App\Support\DnsRecordHints;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
@@ -835,6 +836,83 @@ it('shows cloudflare domain connect only on cloud with a key', function () {
->assertSet('showCloudflareAutoconfigureModal', false);
});
+function enableCloudflareDomainConnectForTest(): void
+{
+ $key = openssl_pkey_new([
+ 'private_key_bits' => 2048,
+ 'private_key_type' => OPENSSL_KEYTYPE_RSA,
+ ]);
+ openssl_pkey_export($key, $privateKey);
+
+ config([
+ 'constants.coolify.self_hosted' => false,
+ 'services.domain_connect.provider_id' => 'coolify.io',
+ 'services.domain_connect.service_id' => 'hosting',
+ 'services.domain_connect.key_id' => '_dcpubkeyv1',
+ 'services.domain_connect.private_key' => $privateKey,
+ ]);
+}
+
+it('opens cloudflare domain connect with the public server ip', function () {
+ enableCloudflareDomainConnectForTest();
+ $this->application->update(['fqdn' => 'https://app.example.com']);
+
+ $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->call('openCloudflareAutoconfigureModal')
+ ->assertSet('showCloudflareAutoconfigureModal', true)
+ ->call('applyCloudflareAutoconfigure')
+ ->assertNotDispatched('error');
+
+ expect(collect($component->effects['xjs'] ?? [])->pluck('expression')->implode("\n"))
+ ->toContain('dash.cloudflare.com')
+ ->toContain('203.0.113.10');
+});
+
+it('does not start cloudflare domain connect for a server without a public ip', function () {
+ enableCloudflareDomainConnectForTest();
+ $this->server->update(['ip' => '10.221.1.11']);
+ $this->application->update(['fqdn' => 'https://app.example.com']);
+
+ $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->assertSet('serverIp', '10.221.1.11')
+ ->call('openCloudflareAutoconfigureModal')
+ ->assertDispatched('error', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE)
+ ->assertSet('showCloudflareAutoconfigureModal', false)
+ ->call('applyCloudflareAutoconfigure')
+ ->assertDispatched('error', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE)
+ ->assertSet('showCloudflareAutoconfigureModal', false);
+
+ expect($component->effects['xjs'] ?? [])->toBeEmpty();
+});
+
+it('uses only a public address for cloudflare domain connect on localhost', function () {
+ enableCloudflareDomainConnectForTest();
+ InstanceSettings::get()->update(['public_ipv4' => '198.51.100.20']);
+ $localhost = Server::factory()->create([
+ 'id' => 0,
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->server->private_key_id,
+ 'ip' => 'localhost',
+ ]);
+ $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::forceCreate([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'localhost-docker',
+ 'network' => 'coolify-localhost',
+ 'server_id' => $localhost->id,
+ ]));
+ $this->application->update([
+ 'destination_id' => $destination->id,
+ 'fqdn' => 'https://app.example.com',
+ ]);
+
+ $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->call('applyCloudflareAutoconfigure')
+ ->assertNotDispatched('error');
+
+ expect(collect($component->effects['xjs'] ?? [])->pluck('expression')->implode("\n"))
+ ->toContain('198.51.100.20');
+});
+
it('adds a domain to the application', function () {
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSee('Add domain')