mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Closes #12436 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
392 lines
14 KiB
PHP
392 lines
14 KiB
PHP
<?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;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
it('returns a skipped dns result when instance validation is disabled', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
|
['id' => 0],
|
|
['is_dns_validation_enabled' => false]
|
|
));
|
|
|
|
$result = CheckDomainDns::run(
|
|
['https://example.com' => 'https://example.com'],
|
|
new Server(['ip' => '203.0.113.10']),
|
|
'203.0.113.10',
|
|
);
|
|
|
|
expect($result['https://example.com'])
|
|
->toMatchArray([
|
|
'status' => 'skipped',
|
|
'message' => 'DNS validation is disabled in instance settings.',
|
|
'expected_ip' => '203.0.113.10',
|
|
])
|
|
->and($result['https://example.com']['checked_at'])->not->toBeNull();
|
|
});
|
|
|
|
it('stops querying DNS servers after finding a matching IP', function (string $resolvedIp) {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
|
['id' => 0],
|
|
[
|
|
'is_dns_validation_enabled' => true,
|
|
'custom_dns_servers' => '192.0.2.1,192.0.2.2',
|
|
]
|
|
));
|
|
|
|
$queriedServers = new ArrayObject;
|
|
$targetIp = '203.0.113.10';
|
|
|
|
app()->bind(DNSQuery::class, function ($app, array $parameters) use ($queriedServers, $resolvedIp) {
|
|
return new class($parameters['server'], $queriedServers, $resolvedIp) extends DNSQuery
|
|
{
|
|
public function __construct(
|
|
private readonly string $dnsServer,
|
|
private readonly ArrayObject $queriedServers,
|
|
private readonly string $resolvedIp,
|
|
) {
|
|
parent::__construct($dnsServer);
|
|
}
|
|
|
|
public function query(string $question, string $typeName = DNSTypes::NAME_A)
|
|
{
|
|
$this->queriedServers->append($this->dnsServer);
|
|
|
|
return [new DNSResult($typeName, 1, 'IN', 60, $this->resolvedIp, $question, '', [])];
|
|
}
|
|
|
|
public function hasError(): bool
|
|
{
|
|
return false;
|
|
}
|
|
};
|
|
});
|
|
|
|
$server = new Server(['ip' => $targetIp]);
|
|
$server->id = 1;
|
|
|
|
expect(validateDNSEntry('https://example.com', $server))->toBeTrue()
|
|
->and($queriedServers->getArrayCopy())->toBe(['192.0.2.1']);
|
|
|
|
$result = CheckDomainDns::run(['example' => 'https://example.com'], $server, $targetIp);
|
|
|
|
expect($result['example']['status'])->toBe('ok')
|
|
->and($queriedServers->getArrayCopy())->toBe(['192.0.2.1', '192.0.2.1']);
|
|
})->with([
|
|
'target server IP' => '203.0.113.10',
|
|
'Cloudflare IP' => '104.16.0.1',
|
|
]);
|
|
|
|
it('does not start another resolver query after the total dns budget is exhausted', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
|
['id' => 0],
|
|
[
|
|
'is_dns_validation_enabled' => true,
|
|
'custom_dns_servers' => '192.0.2.1,192.0.2.2',
|
|
]
|
|
));
|
|
|
|
$queryCount = new ArrayObject;
|
|
app()->bind(DNSQuery::class, function () use ($queryCount) {
|
|
$queryCount->append(true);
|
|
|
|
return new DNSQuery('192.0.2.1');
|
|
});
|
|
|
|
$result = CheckDomainDns::run(
|
|
['example' => 'https://example.com'],
|
|
new Server(['ip' => '203.0.113.10']),
|
|
'203.0.113.10',
|
|
timeoutSeconds: 0,
|
|
);
|
|
|
|
expect($result['example']['status'])->toBe('failed')
|
|
->and($result['example']['message'])->toBe('Could not validate DNS for this domain.')
|
|
->and($queryCount)->toHaveCount(0);
|
|
});
|
|
|
|
it('falls back to the system resolver when custom dns servers return no answers', 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): false
|
|
{
|
|
return false;
|
|
}
|
|
|
|
public function hasError(): bool
|
|
{
|
|
return true;
|
|
}
|
|
});
|
|
|
|
$action = new class extends CheckDomainDns
|
|
{
|
|
protected function resolveWithSystemDns(string $host, string $type): array
|
|
{
|
|
return ['203.0.113.10'];
|
|
}
|
|
};
|
|
|
|
$result = $action->handle(
|
|
['example' => 'https://example.com'],
|
|
new Server(['ip' => '203.0.113.10']),
|
|
'203.0.113.10',
|
|
);
|
|
|
|
expect($result['example']['status'])->toBe('ok');
|
|
});
|
|
|
|
it('does not use the system resolver after a custom dns server returns an address', 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): array
|
|
{
|
|
return [new DNSResult($typeName, 1, 'IN', 60, '198.51.100.20', $question, '', [])];
|
|
}
|
|
|
|
public function hasError(): bool
|
|
{
|
|
return false;
|
|
}
|
|
});
|
|
|
|
$action = new class extends CheckDomainDns
|
|
{
|
|
protected function resolveWithSystemDns(string $host, string $type): array
|
|
{
|
|
throw new RuntimeException('The system resolver must not be used.');
|
|
}
|
|
};
|
|
|
|
$result = $action->handle(
|
|
['example' => 'https://example.com'],
|
|
new Server(['ip' => '203.0.113.10']),
|
|
'203.0.113.10',
|
|
);
|
|
|
|
expect($result['example']['status'])->toBe('failed');
|
|
});
|
|
|
|
it('treats equivalent IPv6 notations as the same address', function (string $resolvedIp, string $expectedIp) {
|
|
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', $resolvedIp) extends DNSQuery
|
|
{
|
|
public function __construct(string $server, private readonly string $resolvedIp)
|
|
{
|
|
parent::__construct($server);
|
|
}
|
|
|
|
public function query(string $question, string $typeName = DNSTypes::NAME_A)
|
|
{
|
|
return [new DNSResult($typeName, 1, 'IN', 60, $this->resolvedIp, $question, '', [])];
|
|
}
|
|
|
|
public function hasError(): bool
|
|
{
|
|
return false;
|
|
}
|
|
});
|
|
|
|
$server = new Server(['ip' => $expectedIp]);
|
|
$server->id = 1;
|
|
|
|
$result = CheckDomainDns::run(['example' => 'https://example.com'], $server, $expectedIp);
|
|
|
|
expect($result['example']['status'])->toBe('ok');
|
|
})->with([
|
|
'compressed record, expanded expectation' => ['2001:db8::1', '2001:0db8:0:0::1'],
|
|
'expanded record, compressed expectation' => ['2001:0DB8:0000:0000:0000:0000:0000:0001', '2001:db8::1'],
|
|
]);
|
|
|
|
it('treats equivalent IPv6 notations from the system resolver as the same address', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
|
['id' => 0],
|
|
['is_dns_validation_enabled' => true, 'custom_dns_servers' => ''],
|
|
));
|
|
|
|
$action = new class extends CheckDomainDns
|
|
{
|
|
protected function resolveWithSystemDns(string $host, string $type): array
|
|
{
|
|
return ['2001:db8::1'];
|
|
}
|
|
};
|
|
$server = new Server(['ip' => '2001:0db8:0:0::1']);
|
|
$server->id = 1;
|
|
|
|
expect($action->handle(['example' => 'https://example.com'], $server, '2001:0db8:0:0::1')['example']['status'])->toBe('ok');
|
|
});
|
|
|
|
it('validateDNSEntry treats equivalent IPv6 notations as the same address', 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, '2001:db8::1', $question, '', [])];
|
|
}
|
|
|
|
public function hasError(): bool
|
|
{
|
|
return false;
|
|
}
|
|
});
|
|
|
|
$server = new Server(['ip' => '2001:0db8:0:0::1']);
|
|
$server->id = 1;
|
|
|
|
expect(validateDNSEntry('https://example.com', $server))->toBeTrue();
|
|
});
|
|
|
|
it('passes bracketed IPv6 custom dns servers to the dns query', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
|
['id' => 0],
|
|
[
|
|
'is_dns_validation_enabled' => true,
|
|
'custom_dns_servers' => '2606:4700:4700::1111,[2001:db8::53],192.0.2.1',
|
|
]
|
|
));
|
|
|
|
$queriedServers = new ArrayObject;
|
|
app()->bind(DNSQuery::class, function ($app, array $parameters) use ($queriedServers) {
|
|
$queriedServers->append($parameters['server']);
|
|
|
|
return new class('192.0.2.1') extends DNSQuery
|
|
{
|
|
public function query(string $question, string $typeName = DNSTypes::NAME_A): false
|
|
{
|
|
return false;
|
|
}
|
|
|
|
public function hasError(): bool
|
|
{
|
|
return true;
|
|
}
|
|
};
|
|
});
|
|
|
|
$action = new class extends CheckDomainDns
|
|
{
|
|
protected function resolveWithSystemDns(string $host, string $type): array
|
|
{
|
|
return [];
|
|
}
|
|
};
|
|
|
|
$action->handle(['example' => 'https://example.com'], new Server(['ip' => '203.0.113.10']), '203.0.113.10');
|
|
|
|
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, string $hostname = 'app.example.com') {
|
|
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://{$hostname}"], $server, '203.0.113.10');
|
|
|
|
expect($result['app']['status'])->toBe($status)
|
|
->and(validateDNSEntry("https://{$hostname}", $server))->toBe($status === 'ok');
|
|
if ($status === 'failed') {
|
|
expect($result['app']['message'])->toBe("Cloudflare sends {$hostname} 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'],
|
|
'wildcard of the zone covers a deeper hostname' => [[['type' => 'A', 'name' => '*.example.com', 'content' => '198.51.100.20']], 'failed', 'app.apps.example.com'],
|
|
'closest wildcard wins for a deeper hostname' => [[
|
|
['type' => 'A', 'name' => '*.apps.example.com', 'content' => '203.0.113.10'],
|
|
['type' => 'A', 'name' => '*.example.com', 'content' => '198.51.100.20'],
|
|
], 'ok', 'app.apps.example.com'],
|
|
'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');
|
|
});
|