mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(dns): compare IPv6 addresses by value
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Shared;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Support\DnsRecordHints;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use PurplePixie\PhpDns\DNSQuery;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
@@ -92,7 +93,7 @@ class CheckDomainDns
|
||||
}
|
||||
|
||||
$receivedAddressRecord = true;
|
||||
if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) {
|
||||
if (isCloudflareIp($record->getData()) || ($expectedIp && DnsRecordHints::sameAddress($record->getData(), $expectedIp))) {
|
||||
return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp);
|
||||
}
|
||||
}
|
||||
@@ -103,7 +104,7 @@ class CheckDomainDns
|
||||
|
||||
if (! $receivedAddressRecord && hrtime(true) < $deadline) {
|
||||
foreach ($this->resolveWithSystemDns($host, $type) as $resolvedIp) {
|
||||
if (isCloudflareIp($resolvedIp) || ($expectedIp && $resolvedIp === $expectedIp)) {
|
||||
if (isCloudflareIp($resolvedIp) || ($expectedIp && DnsRecordHints::sameAddress($resolvedIp, $expectedIp))) {
|
||||
return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +392,11 @@ trait InteractsWithDnsProviders
|
||||
{
|
||||
$addresses = $this->publicServerIpsForDnsProvider();
|
||||
|
||||
return $requested === null ? ($addresses[0] ?? null) : (in_array($requested, $addresses, true) ? $requested : null);
|
||||
if ($requested === null) {
|
||||
return $addresses[0] ?? null;
|
||||
}
|
||||
|
||||
return collect($addresses)->first(fn (string $address): bool => DnsRecordHints::sameAddress($address, $requested));
|
||||
}
|
||||
|
||||
abstract protected function persistDomainDnsStatuses(): void;
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Exceptions\DnsRecordConflictException;
|
||||
use App\Models\DnsProviderZone;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\ManagedDnsRecord;
|
||||
use App\Support\DnsRecordHints;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
@@ -132,7 +133,7 @@ class CloudflareDnsProvider
|
||||
{
|
||||
$type = $this->recordType($content);
|
||||
$remoteRecords = $this->findRecords($zone, $hostname, $type);
|
||||
$matching = collect($remoteRecords)->first(fn (array $remote): bool => $remote['content'] === $content);
|
||||
$matching = collect($remoteRecords)->first(fn (array $remote): bool => DnsRecordHints::sameAddress($remote['content'], $content));
|
||||
if ($matching !== null) {
|
||||
if ($matching['id'] === '') {
|
||||
throw new RuntimeException('Cloudflare DNS records could not be checked.');
|
||||
@@ -222,7 +223,7 @@ class CloudflareDnsProvider
|
||||
if ($remote === null
|
||||
|| $remote['id'] === ''
|
||||
|| $remote['id'] !== $recordId
|
||||
|| ($expectedCurrent !== null && $remote['content'] !== $expectedCurrent)
|
||||
|| ($expectedCurrent !== null && ! DnsRecordHints::sameAddress($remote['content'], $expectedCurrent))
|
||||
|| $remote['name'] !== $hostname) {
|
||||
throw new RuntimeException('The DNS conflict is no longer available. Check the record again.');
|
||||
}
|
||||
@@ -265,7 +266,7 @@ class CloudflareDnsProvider
|
||||
}
|
||||
if (($remote['type'] ?? null) !== $record->type
|
||||
|| strtolower((string) ($remote['name'] ?? '')) !== $record->name
|
||||
|| ($remote['content'] ?? null) !== $record->content
|
||||
|| ! DnsRecordHints::sameAddress(is_string($remote['content'] ?? null) ? $remote['content'] : null, $record->content)
|
||||
|| ($remote['comment'] ?? null) !== $record->ownershipComment()) {
|
||||
return ManagedDnsDeletionResult::ChangedExternally;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,25 @@ class DnsRecordHints
|
||||
return ord($binary[0]) !== 0xFF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two DNS record values name the same address. IP addresses are compared in binary form,
|
||||
* so equivalent IPv6 notations (2001:db8::1 and 2001:0DB8:0:0::1) match; other values must be identical.
|
||||
*/
|
||||
public static function sameAddress(?string $first, ?string $second): bool
|
||||
{
|
||||
if ($first === null || $second === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$firstBinary = @inet_pton(trim($first));
|
||||
$secondBinary = @inet_pton(trim($second));
|
||||
if ($firstBinary !== false && $secondBinary !== false) {
|
||||
return $firstBinary === $secondBinary;
|
||||
}
|
||||
|
||||
return $first === $second;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build A/AAAA entries for every hostname (deduped).
|
||||
*
|
||||
|
||||
@@ -32,6 +32,7 @@ use App\Models\StandaloneSqlite;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Support\DnsRecordHints;
|
||||
use Carbon\CarbonImmutable;
|
||||
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
@@ -2323,7 +2324,7 @@ function validateDNSEntry(string $fqdn, Server $server)
|
||||
$found_matching_ip = true;
|
||||
break 2;
|
||||
}
|
||||
if ($ip && $result->getData() === $ip) {
|
||||
if ($ip && DnsRecordHints::sameAddress($result->getData(), $ip)) {
|
||||
$found_matching_ip = true;
|
||||
break 2;
|
||||
}
|
||||
|
||||
@@ -74,3 +74,16 @@ it('treats only publicly routable addresses as public dns record targets', funct
|
||||
'not an ip' => ['app.example.com', false],
|
||||
'empty' => ['', false],
|
||||
]);
|
||||
|
||||
it('compares IP addresses independent of their notation', function (string $first, string $second, bool $same) {
|
||||
expect(DnsRecordHints::sameAddress($first, $second))->toBe($same);
|
||||
})->with([
|
||||
'compressed vs expanded IPv6' => ['2001:db8::1', '2001:0db8:0:0::1', true],
|
||||
'upper vs lower case IPv6' => ['2001:DB8::A', '2001:db8::a', true],
|
||||
'fully expanded IPv6' => ['2001:0db8:0000:0000:0000:0000:0000:0001', '2001:db8::1', true],
|
||||
'different IPv6' => ['2001:db8::1', '2001:db8::2', false],
|
||||
'same IPv4' => ['203.0.113.10', '203.0.113.10', true],
|
||||
'different IPv4' => ['203.0.113.10', '203.0.113.11', false],
|
||||
'IPv4 vs IPv6' => ['203.0.113.10', '::ffff:203.0.113.10', false],
|
||||
'non-IP values' => ['target.example.com', 'target.example.com', true],
|
||||
]);
|
||||
|
||||
@@ -188,3 +188,82 @@ it('does not use the system resolver after a custom dns server returns an addres
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ManagedDnsDeletionResult;
|
||||
use App\Jobs\ReleaseManagedDnsRecordsJob;
|
||||
use App\Livewire\Project\Application\Domains;
|
||||
use App\Livewire\Project\Service\Domains as ServiceDomains;
|
||||
@@ -435,3 +436,30 @@ test('releasing a hostname never touches records of another team', function () {
|
||||
->and($foreignRecord->fresh())->not->toBeNull()
|
||||
->and($foreignRecord->references()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('an existing AAAA record written in another IPv6 notation satisfies the request', function () {
|
||||
fakeCloudflareDns([
|
||||
['id' => 'record-1', 'type' => 'AAAA', 'name' => 'app.example.com', 'content' => '2001:db8::1', 'comment' => null],
|
||||
]);
|
||||
$application = createDnsTestApplication($this, 'https://app.example.com');
|
||||
|
||||
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '2001:0DB8:0:0::1', $application);
|
||||
|
||||
expect($record->provider_record_id)->toBe('record-1')->and($record->owned)->toBeFalse();
|
||||
expect(sentDnsRequests('POST'))->toBe(0);
|
||||
});
|
||||
|
||||
test('an owned AAAA record is deleted when the provider returns it in canonical IPv6 notation', function () {
|
||||
$cloudflare = fakeCloudflareDns();
|
||||
$application = createDnsTestApplication($this, 'https://app.example.com');
|
||||
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '2001:0db8:0000:0000:0000:0000:0000:0001', $application);
|
||||
|
||||
$records = $cloudflare['records'];
|
||||
$records[$record->provider_record_id]['content'] = '2001:db8::1';
|
||||
$cloudflare['records'] = $records;
|
||||
|
||||
$result = app(CloudflareDnsProvider::class)->deleteRecord($record->fresh(), $application);
|
||||
|
||||
expect($result)->toBe(ManagedDnsDeletionResult::Deleted)
|
||||
->and($cloudflare['records'])->not->toHaveKey($record->provider_record_id);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user