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.
This commit is contained in:
Andras Bacsai
2026-09-30 19:23:35 +02:00
parent e8337c73eb
commit 0ab19998d5
3 changed files with 108 additions and 1 deletions
@@ -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) {
@@ -82,7 +82,7 @@
</div>
<x-forms.input label="Server IP (A record target)"
value="{{ $serverIp ?: 'Unavailable' }}" readonly
value="{{ $this->publicServerIpForDomainConnect() ?: 'Unavailable' }}" readonly
helper="This IP is taken from the destination server." />
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
+78
View File
@@ -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')