diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 66310c104e..16a7f0ad9b 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -30,6 +30,8 @@ class IntegrationTokenEditor extends Component public bool $automaticDns = true; + public bool $proxiedDns = false; + public function mount(string $integration_token_uuid): void { $this->integrationToken = IntegrationToken::ownedByCurrentTeam() @@ -43,6 +45,7 @@ class IntegrationTokenEditor extends Component $this->metadata = $this->integrationToken->metadata ?? []; $this->loadZones(); $this->automaticDns = $this->integrationToken->automaticDnsEnabled(); + $this->proxiedDns = $this->integrationToken->proxiedDnsEnabled(); } protected function rules(): array @@ -55,6 +58,7 @@ class IntegrationTokenEditor extends Component 'capabilities' => ['required', 'array', 'min:1'], 'capabilities.*' => ['required', 'in:'.$allowedCapability], 'automaticDns' => ['boolean'], + 'proxiedDns' => ['boolean'], ]; if ($this->integrationToken->provider === 'infisical') { @@ -91,6 +95,11 @@ class IntegrationTokenEditor extends Component } else { $metadata['automatic_dns'] = false; } + if ($validated['proxiedDns']) { + $metadata['proxied_dns'] = true; + } else { + unset($metadata['proxied_dns']); + } } $storedMetadata = $this->integrationToken->metadata ?? []; $changedConnectionFields = $this->changedConnectionFields($provider, $storedMetadata, $metadata); diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index e07e22db0b..3cfbc75aa7 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -27,6 +27,8 @@ class IntegrationTokenForm extends Component public bool $automaticDns = true; + public bool $proxiedDns = false; + public function mount(): void { $this->authorize('create', IntegrationToken::class); @@ -38,6 +40,7 @@ class IntegrationTokenForm extends Component $this->capabilities = ['dns']; $this->metadata = []; $this->automaticDns = true; + $this->proxiedDns = false; } else { $this->capabilities = ['secrets']; $this->metadata = $this->provider === 'infisical' @@ -57,6 +60,7 @@ class IntegrationTokenForm extends Component 'capabilities' => ['required', 'array', 'min:1'], 'capabilities.*' => ['required', 'in:'.$allowedCapability], 'automaticDns' => ['boolean'], + 'proxiedDns' => ['boolean'], ]; if ($this->provider === 'infisical') { @@ -94,6 +98,9 @@ class IntegrationTokenForm extends Component if ($validated['provider'] === 'cloudflare' && ! $validated['automaticDns']) { $metadata['automatic_dns'] = false; } + if ($validated['provider'] === 'cloudflare' && $validated['proxiedDns']) { + $metadata['proxied_dns'] = true; + } try { if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) { diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 25c2f55939..119c8d95df 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -76,6 +76,12 @@ class IntegrationToken extends BaseModel return $this->provider === 'cloudflare' && data_get($this->metadata, 'automatic_dns', true) !== false; } + /** New Cloudflare records are created proxied (orange cloud) when enabled, so the server IP is not exposed. */ + public function proxiedDnsEnabled(): bool + { + return $this->provider === 'cloudflare' && data_get($this->metadata, 'proxied_dns') === true; + } + public function dopplerTokenType(): ?string { if ($this->provider !== 'doppler') { diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php index 9cc64beb4b..995a632f8a 100644 --- a/app/Services/Dns/CloudflareDnsProvider.php +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -153,7 +153,8 @@ class CloudflareDnsProvider $uuid = new_public_id(); $response = $this->client($zone->integrationToken)->post("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [ - 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false, + 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, + 'proxied' => $zone->integrationToken->proxiedDnsEnabled(), 'comment' => ManagedDnsRecord::ownershipCommentFor($uuid), ]); if (! $response->successful() || ! is_string($response->json('result.id'))) { diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php index 9a558939ba..ed7d108aed 100644 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -50,6 +50,13 @@ Create DNS records automatically when a new domain has one unambiguous matching credential.

+
+ +

+ Create new DNS records with the Cloudflare proxy on (orange cloud), so the server IP is not public. Existing records keep their proxy status. +

+
@else
Capability: Secrets (read-only)
diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php index 3274c34baf..a085ebbe1a 100644 --- a/resources/views/livewire/security/integration-token-form.blade.php +++ b/resources/views/livewire/security/integration-token-form.blade.php @@ -58,6 +58,13 @@ Create DNS records automatically when a new domain has one unambiguous matching credential.

+
+ +

+ Create new DNS records with the Cloudflare proxy on (orange cloud), so the server IP is not public. Existing records keep their proxy status. +

+
@else
Capability: Secrets (read-only)
diff --git a/tests/Feature/DnsProviderManagementTest.php b/tests/Feature/DnsProviderManagementTest.php index cf7f2d5975..bf3fabb712 100644 --- a/tests/Feature/DnsProviderManagementTest.php +++ b/tests/Feature/DnsProviderManagementTest.php @@ -120,6 +120,23 @@ test('a cloudflare record is created and tracked as managed by coolify', functio && $request->data()['comment'] === $record->ownershipComment()); }); +test('cloudflare records are created proxied only when the credential enables proxy mode', function (?array $metadata, bool $proxied) { + $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret', 'metadata' => $metadata]); + $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); + + Http::fake([ + 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]), + 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]), + ]); + + app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10'); + + Http::assertSent(fn ($request) => $request->method() === 'POST' && $request->data()['proxied'] === $proxied); +})->with([ + 'default' => [null, false], + 'enabled' => [['proxied_dns' => true], true], +]); + test('queued dns configuration creates the record and broadcasts completion', function () { Event::fake([DnsRecordConfigurationFinished::class]); $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php index 0c70c868b5..41d18784ee 100644 --- a/tests/Feature/Security/IntegrationTokenFormTest.php +++ b/tests/Feature/Security/IntegrationTokenFormTest.php @@ -102,6 +102,38 @@ test('automatic dns is enabled by default and can be disabled when saving a clou expect(IntegrationToken::query()->sole()->automaticDnsEnabled())->toBeFalse(); }); +test('cloudflare proxy mode is off by default and can be enabled when adding and editing a token', function () { + Http::fake([ + 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response(['success' => true, 'result' => ['status' => 'active']]), + 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response(['success' => true, 'result' => [['id' => 'zone-id']]]), + 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response(['success' => true, 'result' => []]), + 'https://api.cloudflare.com/client/v4/zones?page=1&per_page=50' => Http::response([ + 'success' => true, + 'result' => [['id' => 'zone-id', 'name' => 'example.com', 'account' => ['id' => 'account-id', 'name' => 'Production']]], + 'result_info' => ['total_pages' => 1], + ]), + ]); + + Livewire::test(IntegrationTokenForm::class) + ->assertSet('proxiedDns', false) + ->set('name', 'Proxied DNS') + ->set('token', 'cloudflare-token') + ->set('proxiedDns', true) + ->call('addToken') + ->assertHasNoErrors(); + + $token = IntegrationToken::query()->sole(); + expect($token->proxiedDnsEnabled())->toBeTrue(); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $token->uuid]) + ->assertSet('proxiedDns', true) + ->set('proxiedDns', false) + ->call('save') + ->assertHasNoErrors(); + + expect($token->refresh()->proxiedDnsEnabled())->toBeFalse(); +}); + test('deleting an integration token is audited without storing its value', function () { $token = IntegrationToken::query()->create([ 'team_id' => $this->team->id,