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')