mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(dns): verify the Cloudflare origin and add records for clones and API-created resources
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7ec000ab86
commit
b7defddc32
@@ -26,6 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
- Do not copy the preview deployments of an application when you clone it. A clone of an application with a fixed preview URL template (for example `demo-mr-{{pr_id}}.example.com`) failed with a duplicate preview domain error and left a stopped, partial clone. The clone gets its own previews from new pull requests
|
||||
- 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"
|
||||
- Add the Cloudflare DNS record automatically, or open the **Configure DNS** dialog, also when you edit a domain to a hostname in a connected Cloudflare zone, for example when you change a generated domain to your own domain. Before, Coolify did this only when you added a new domain. This applies to the **Domains** pages, the application and service application settings, and the service application API
|
||||
- Show "DNS mismatch" when a domain in a connected Cloudflare zone goes through Cloudflare to a different server. Before, the DNS check accepted every Cloudflare IP, so a proxied wildcard record that points to another server showed "DNS matches", and the domain then failed with Cloudflare error 526 and a Let's Encrypt error. Coolify now reads the Cloudflare record of the domain, or else its wildcard record, and compares the IP with the server. CNAME records, for example of a Cloudflare Tunnel, are still accepted ([#12436](https://github.com/coollabsio/coolify/issues/12436))
|
||||
- Add the Cloudflare DNS record automatically when you clone an application or a service, and when you create an application or a service through the API, for each domain in a Cloudflare zone with automatic DNS. A clone gets records only for the domains that the source does not use, so the record of the source stays as it is ([#12436](https://github.com/coollabsio/coolify/issues/12436))
|
||||
- Save deployments started through the MCP server as MCP deployments, not as API deployments. The deployment list shows them with the **MCP** source and filter, and the API and MCP deployment data have a new `is_mcp` field. The deployments to additional servers keep the source of the main deployment, and the audit log records actions that MCP tools do through the API as MCP events
|
||||
- Save the update channel in **Settings > Updates** when you select **Preview** or **Nightly**. Before, the "Save changes" bar did not open for these channels, so the change was lost when you refreshed the page
|
||||
- Save the healthcheck **Check type** when it is the only change. Before, the "Save changes" bar did not open when you changed only the check type, for example from **Container command** back to **HTTP request**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Shared;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\Dns\CloudflareDnsProvider;
|
||||
use App\Support\DnsRecordHints;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
@@ -81,15 +82,20 @@ class CheckDomainDns
|
||||
continue;
|
||||
}
|
||||
|
||||
$addresses = [];
|
||||
foreach ($records as $record) {
|
||||
if ($record->getType() !== $type) {
|
||||
continue;
|
||||
if ($record->getType() === $type) {
|
||||
$addresses[] = $record->getData();
|
||||
}
|
||||
}
|
||||
if ($addresses === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$receivedAddressRecord = true;
|
||||
if (isCloudflareIp($record->getData()) || ($expectedIp && DnsRecordHints::sameAddress($record->getData(), $expectedIp))) {
|
||||
return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp);
|
||||
}
|
||||
$receivedAddressRecord = true;
|
||||
$result = $this->resultForAddresses($addresses, $host, $server, $expectedIp, $deadline);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
@@ -97,16 +103,36 @@ class CheckDomainDns
|
||||
}
|
||||
|
||||
if (! $receivedAddressRecord && hrtime(true) < $deadline) {
|
||||
foreach ($this->resolveWithSystemDns($host, $type) as $resolvedIp) {
|
||||
if (isCloudflareIp($resolvedIp) || ($expectedIp && DnsRecordHints::sameAddress($resolvedIp, $expectedIp))) {
|
||||
return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp);
|
||||
}
|
||||
$result = $this->resultForAddresses($this->resolveWithSystemDns($host, $type), $host, $server, $expectedIp, $deadline);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp);
|
||||
}
|
||||
|
||||
/**
|
||||
* The result for the addresses that DNS returned for the host, or null when none of them matches.
|
||||
* A Cloudflare IP matches unless the Cloudflare records of the team's zone send the host to another server.
|
||||
*
|
||||
* @param array<int, string> $addresses
|
||||
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}|null
|
||||
*/
|
||||
private function resultForAddresses(array $addresses, string $host, Server $server, ?string $expectedIp, int $deadline): ?array
|
||||
{
|
||||
$matches = collect($addresses)->contains(fn (string $address): bool => $expectedIp && DnsRecordHints::sameAddress($address, $expectedIp));
|
||||
if (! $matches && collect($addresses)->contains(fn (string $address): bool => isCloudflareIp($address))) {
|
||||
$timeoutSeconds = max(1, intdiv($deadline - hrtime(true), 1_000_000_000));
|
||||
if (app(CloudflareDnsProvider::class)->sendsHostnameToAnotherServer($server, $host, $expectedIp, $timeoutSeconds)) {
|
||||
return $this->result('failed', "Cloudflare sends {$host} to another server. ".dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp);
|
||||
}
|
||||
$matches = true;
|
||||
}
|
||||
|
||||
return $matches ? $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
|
||||
@@ -1768,6 +1768,7 @@ class ApplicationsController extends Controller
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
app(ResourceDnsRecords::class)->queueAutomaticRecords($application, []);
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
@@ -2031,6 +2032,7 @@ class ApplicationsController extends Controller
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
app(ResourceDnsRecords::class)->queueAutomaticRecords($application, []);
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
@@ -2263,6 +2265,7 @@ class ApplicationsController extends Controller
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
app(ResourceDnsRecords::class)->queueAutomaticRecords($application, []);
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
@@ -2402,6 +2405,7 @@ class ApplicationsController extends Controller
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
app(ResourceDnsRecords::class)->queueAutomaticRecords($application, []);
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
@@ -2537,6 +2541,7 @@ class ApplicationsController extends Controller
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
app(ResourceDnsRecords::class)->queueAutomaticRecords($application, []);
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
|
||||
@@ -611,6 +611,8 @@ class ServicesController extends Controller
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
app(ResourceDnsRecords::class)->queueAutomaticRecords($service, []);
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
@@ -799,6 +801,8 @@ class ServicesController extends Controller
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
app(ResourceDnsRecords::class)->queueAutomaticRecords($service, []);
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\ServiceApplication;
|
||||
use App\Support\DnsRecordHints;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -95,10 +96,10 @@ class CloudflareDnsProvider
|
||||
*
|
||||
* @return array<int, array{id: string, type: string, name: string, content: string, proxied: bool|null, ttl: int|null, comment: string|null}>
|
||||
*/
|
||||
public function findRecords(DnsProviderZone $zone, string $hostname, ?string $type): array
|
||||
public function findRecords(DnsProviderZone $zone, string $hostname, ?string $type, int $timeoutSeconds = 30): array
|
||||
{
|
||||
$hostname = strtolower(rtrim($hostname, '.'));
|
||||
$response = $this->client($zone->integrationToken)->get(
|
||||
$response = $this->client($zone->integrationToken)->timeout($timeoutSeconds)->get(
|
||||
"https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records",
|
||||
array_filter(['type' => $type, 'name' => $hostname, 'per_page' => 100]),
|
||||
);
|
||||
@@ -116,6 +117,51 @@ class CloudflareDnsProvider
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Cloudflare sends a proxied hostname to another server than the given one. A Cloudflare IP in DNS hides
|
||||
* the origin, so the zone's records are read: the hostname's own record, or else the wildcard record that covers it.
|
||||
* False when Coolify cannot tell: no zone of the server's team manages the hostname, the records cannot be read,
|
||||
* a CNAME sends the hostname on, or no record is found.
|
||||
*/
|
||||
public function sendsHostnameToAnotherServer(Server $server, string $hostname, ?string $expectedIp = null, int $timeoutSeconds = 5): bool
|
||||
{
|
||||
$hostname = strtolower(rtrim($hostname, '.'));
|
||||
$zone = $server->team_id === null ? null : $this->findZones($server->team_id, $hostname)->first();
|
||||
if ($zone === null) {
|
||||
return false;
|
||||
}
|
||||
$addresses = array_filter([$expectedIp, ...ServerDnsAddresses::forServer($server)->addresses()]);
|
||||
if ($addresses === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$names = [$hostname];
|
||||
$parent = str($hostname)->after('.')->value();
|
||||
if ($parent === $zone->name || str_ends_with($parent, '.'.$zone->name)) {
|
||||
$names[] = '*.'.$parent;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($names as $name) {
|
||||
$records = collect($this->findRecords($zone, $name, null, $timeoutSeconds))
|
||||
->filter(fn (array $record): bool => in_array($record['type'], ['A', 'AAAA', 'CNAME'], true));
|
||||
if ($records->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if ($records->contains('type', 'CNAME')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $records->contains(fn (array $record): bool => collect($addresses)
|
||||
->contains(fn (string $address): bool => DnsRecordHints::sameAddress($record['content'], $address)));
|
||||
}
|
||||
} catch (RuntimeException|ConnectionException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that block a new record of the type. A CNAME cannot share its name with A, AAAA, or other CNAME records.
|
||||
*
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\EnvironmentVariable;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Notifications\Application\DeploymentFailed;
|
||||
use App\Services\Dns\ResourceDnsRecords;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -611,6 +612,9 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
});
|
||||
}
|
||||
|
||||
$dnsRecords = app(ResourceDnsRecords::class);
|
||||
$dnsRecords->queueAutomaticRecords($newApplication, array_keys($dnsRecords->hostnameUsers($source)));
|
||||
|
||||
return $newApplication;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Services\Dns\ResourceDnsRecords;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Stringable;
|
||||
@@ -790,5 +791,8 @@ function clone_service(Service $source, StandaloneDocker|SwarmDocker $destinatio
|
||||
));
|
||||
}
|
||||
|
||||
$dnsRecords = app(ResourceDnsRecords::class);
|
||||
$dnsRecords->queueAutomaticRecords($newService, array_keys($dnsRecords->hostnameUsers($source)));
|
||||
|
||||
return $newService;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ use App\Models\StandaloneSqlite;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\Dns\CloudflareDnsProvider;
|
||||
use App\Services\Security\SensitiveDataRedactor;
|
||||
use App\Support\DnsRecordHints;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -2519,12 +2520,12 @@ function validateDNSEntry(string $fqdn, Server $server)
|
||||
if ($results !== false) {
|
||||
foreach ($results as $result) {
|
||||
if ($result->getType() == $type) {
|
||||
if (isCloudflareIp($result->getData())) {
|
||||
if ($ip && DnsRecordHints::sameAddress($result->getData(), $ip)) {
|
||||
$found_matching_ip = true;
|
||||
break 2;
|
||||
}
|
||||
if ($ip && DnsRecordHints::sameAddress($result->getData(), $ip)) {
|
||||
$found_matching_ip = true;
|
||||
if (isCloudflareIp($result->getData())) {
|
||||
$found_matching_ip = ! app(CloudflareDnsProvider::class)->sendsHostnameToAnotherServer($server, $host, $ip);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\Dns\CloudflareDnsProvider;
|
||||
use App\Services\Dns\ManagedDnsHostnameLock;
|
||||
use App\Services\Dns\ResourceDnsRecords;
|
||||
use App\Support\DnsRecordHints;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
@@ -492,6 +493,75 @@ describe('automatic dns on api domain changes', function () {
|
||||
Queue::assertNotPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'web.example.com');
|
||||
});
|
||||
|
||||
test('an application created through the api queues a record for its domain', function () {
|
||||
Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]);
|
||||
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
|
||||
$uuid = $this->withToken(dnsApiToken($this->user, $this->team))
|
||||
->postJson('/api/v1/applications/dockerimage', [
|
||||
'project_uuid' => $this->environment->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'docker_registry_image_name' => 'nginx',
|
||||
'docker_registry_image_tag' => 'latest',
|
||||
'ports_exposes' => '80',
|
||||
'domains' => 'https://api-created.example.com',
|
||||
'instant_deploy' => false,
|
||||
])
|
||||
->assertCreated()
|
||||
->json('uuid');
|
||||
|
||||
Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'api-created.example.com'
|
||||
&& $job->content === '203.0.113.10' && $job->resourceId === Application::query()->where('uuid', $uuid)->value('id'));
|
||||
});
|
||||
|
||||
test('a service created through the api queues a record for its domain', function () {
|
||||
Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]);
|
||||
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
|
||||
$this->withToken(dnsApiToken($this->user, $this->team))
|
||||
->postJson('/api/v1/services', [
|
||||
'project_uuid' => $this->environment->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'docker_compose_raw' => base64_encode("services:\n web:\n image: nginx:alpine\n"),
|
||||
'urls' => [['name' => 'web', 'url' => 'https://web-created.example.com']],
|
||||
'instant_deploy' => false,
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'web-created.example.com'
|
||||
&& $job->resourceType === (new ServiceApplication)->getMorphClass());
|
||||
});
|
||||
|
||||
test('a clone queues records only for the domains that the source does not use', function () {
|
||||
Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]);
|
||||
$this->application->settings()->update(['is_container_label_readonly_enabled' => false]);
|
||||
$this->application->update(['fqdn' => 'https://app.example.com,https://cloned.example.com']);
|
||||
$source = $this->application->fresh();
|
||||
|
||||
$clone = clone_application($source, $this->destination, ['fqdn' => 'https://app.example.com,https://clone-only.example.com']);
|
||||
|
||||
Queue::assertPushed(ConfigureDnsRecordJob::class, 1);
|
||||
Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'clone-only.example.com'
|
||||
&& $job->resourceId === $clone->id);
|
||||
});
|
||||
|
||||
test('a cloned service queues records for its generated domains', function () {
|
||||
Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]);
|
||||
$this->server->settings()->update(['wildcard_domain' => 'https://example.com']);
|
||||
$source = dnsApiService($this, 'https://web.example.com')->service;
|
||||
$source->update(['docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_FQDN_WEB\n"]);
|
||||
|
||||
$clone = clone_service($source->fresh(), $this->destination);
|
||||
|
||||
$cloneHostnames = array_keys(app(ResourceDnsRecords::class)->hostnameUsers($clone));
|
||||
expect($cloneHostnames)->not->toBeEmpty()->not->toContain('web.example.com');
|
||||
foreach ($cloneHostnames as $hostname) {
|
||||
Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === $hostname);
|
||||
}
|
||||
});
|
||||
|
||||
test('no record is queued when automatic dns is off', function () {
|
||||
Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]);
|
||||
$this->token->update(['metadata' => ['automatic_dns' => false]]);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Models\DnsProviderZone;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PurplePixie\PhpDns\DNSQuery;
|
||||
use PurplePixie\PhpDns\DNSResult;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
@@ -307,3 +311,76 @@ it('passes bracketed IPv6 custom dns servers to the dns query', function () {
|
||||
|
||||
expect($queriedServers->getArrayCopy())->toBe(['[2606:4700:4700::1111]', '[2001:db8::53]', '192.0.2.1']);
|
||||
});
|
||||
|
||||
it('checks the Cloudflare record origin when a proxied hostname is in a zone of the team', function (array $records, string $status) {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
||||
['id' => 0],
|
||||
['is_dns_validation_enabled' => true, 'custom_dns_servers' => '192.0.2.1']
|
||||
));
|
||||
app()->bind(DNSQuery::class, fn () => new class('192.0.2.1') extends DNSQuery
|
||||
{
|
||||
public function query(string $question, string $typeName = DNSTypes::NAME_A)
|
||||
{
|
||||
return [new DNSResult($typeName, 1, 'IN', 60, '104.16.0.1', $question, '', [])];
|
||||
}
|
||||
|
||||
public function hasError(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']);
|
||||
DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
|
||||
fakeCloudflareDns(collect($records)->map(fn (array $record, int $index): array => $record + ['id' => "record-{$index}", 'proxied' => true])->all());
|
||||
$server = new Server(['ip' => '203.0.113.10']);
|
||||
$server->team_id = $team->id;
|
||||
|
||||
$result = CheckDomainDns::run(['app' => 'https://app.example.com'], $server, '203.0.113.10');
|
||||
|
||||
expect($result['app']['status'])->toBe($status)
|
||||
->and(validateDNSEntry('https://app.example.com', $server))->toBe($status === 'ok');
|
||||
if ($status === 'failed') {
|
||||
expect($result['app']['message'])->toBe('Cloudflare sends app.example.com to another server. Required DNS record type A pointing to 203.0.113.10');
|
||||
}
|
||||
})->with([
|
||||
'record points to the server' => [[['type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10']], 'ok'],
|
||||
'record points to another server' => [[['type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.20']], 'failed'],
|
||||
'wildcard points to another server' => [[['type' => 'A', 'name' => '*.example.com', 'content' => '198.51.100.20']], 'failed'],
|
||||
'wildcard points to the server' => [[['type' => 'A', 'name' => '*.example.com', 'content' => '203.0.113.10']], 'ok'],
|
||||
'record wins over the wildcard' => [[
|
||||
['type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10'],
|
||||
['type' => 'A', 'name' => '*.example.com', 'content' => '198.51.100.20'],
|
||||
], 'ok'],
|
||||
'CNAME origin cannot be verified' => [[['type' => 'CNAME', 'name' => 'app.example.com', 'content' => 'tunnel.cfargotunnel.com']], 'ok'],
|
||||
'no record found' => [[], 'ok'],
|
||||
]);
|
||||
|
||||
it('keeps a Cloudflare IP as a match when the Cloudflare records cannot be read', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
||||
['id' => 0],
|
||||
['is_dns_validation_enabled' => true, 'custom_dns_servers' => '192.0.2.1']
|
||||
));
|
||||
app()->bind(DNSQuery::class, fn () => new class('192.0.2.1') extends DNSQuery
|
||||
{
|
||||
public function query(string $question, string $typeName = DNSTypes::NAME_A)
|
||||
{
|
||||
return [new DNSResult($typeName, 1, 'IN', 60, '104.16.0.1', $question, '', [])];
|
||||
}
|
||||
|
||||
public function hasError(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']);
|
||||
DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
|
||||
Http::fake(['api.cloudflare.com/*' => Http::response(['success' => false], 403)]);
|
||||
$server = new Server(['ip' => '203.0.113.10']);
|
||||
$server->team_id = $team->id;
|
||||
|
||||
expect(CheckDomainDns::run(['app' => 'https://app.example.com'], $server, '203.0.113.10')['app']['status'])->toBe('ok');
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ function fakeCloudflareDns(array $records = []): ArrayObject
|
||||
if ($id === null && $request->method() === 'GET') {
|
||||
parse_str($matches[2] ?? '', $query);
|
||||
$result = collect($records)->filter(fn (array $record): bool => $record['name'] === ($query['name'] ?? null)
|
||||
&& $record['type'] === ($query['type'] ?? null))->values()->all();
|
||||
&& (! isset($query['type']) || $record['type'] === $query['type']))->values()->all();
|
||||
|
||||
return Http::response(['success' => true, 'result' => $result]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user