Files
coolify/app/Services/Dns/CloudflareDnsProvider.php
T

491 lines
22 KiB
PHP

<?php
namespace App\Services\Dns;
use App\Enums\ManagedDnsDeletionResult;
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;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
class CloudflareDnsProvider
{
/** @var array<int, Collection<int, DnsProviderZone>> */
private array $zoneCache = [];
public function __construct(private ManagedDnsHostnameLock $hostnameLock) {}
public function syncZones(IntegrationToken $token): int
{
unset($this->zoneCache[$token->team_id]);
$zones = [];
$page = 1;
do {
$response = $this->client($token)->get('https://api.cloudflare.com/client/v4/zones', ['page' => $page, 'per_page' => 50]);
if ($response->status() === 403) {
throw new RuntimeException(self::missingPermissionMessage('Zone → Zone → Read'));
}
if (! $response->successful() || $response->json('success') !== true) {
throw new RuntimeException('Cloudflare zones could not be synchronized.');
}
array_push($zones, ...$response->json('result', []));
$totalPages = max(1, (int) $response->json('result_info.total_pages', 1));
$page++;
} while ($page <= $totalPages);
DB::transaction(function () use ($token, $zones): void {
$ids = [];
foreach ($zones as $zone) {
$ids[] = $zone['id'];
$token->dnsZones()->updateOrCreate(['provider_zone_id' => $zone['id']], [
'name' => strtolower($zone['name']), 'account_id' => data_get($zone, 'account.id'),
'account_name' => data_get($zone, 'account.name'),
]);
}
$token->dnsZones()->whereNotIn('provider_zone_id', $ids)->whereDoesntHave('managedRecords')->delete();
$metadata = $token->metadata ?? [];
$metadata['zones_synced_at'] = now()->toIso8601String();
$token->update(['metadata' => $metadata]);
});
return count($zones);
}
/** @return Collection<int, DnsProviderZone> */
public function findZones(int $teamId, string $hostname): Collection
{
$hostname = strtolower(rtrim($hostname, '.'));
$matches = $this->zonesForTeam($teamId)->filter(
fn (DnsProviderZone $zone) => $hostname === $zone->name || str_ends_with($hostname, '.'.$zone->name)
);
$longest = $matches->max(fn (DnsProviderZone $zone) => strlen($zone->name));
return $matches->filter(fn (DnsProviderZone $zone) => strlen($zone->name) === $longest)->values();
}
/** @return Collection<int, DnsProviderZone> */
private function zonesForTeam(int $teamId): Collection
{
return $this->zoneCache[$teamId] ??= DnsProviderZone::query()
->whereHas('integrationToken', fn ($query) => $query->where('team_id', $teamId)->where('provider', 'cloudflare'))
->with('integrationToken')
->get();
}
public static function missingPermissionMessage(string $permission): string
{
return "The Cloudflare token needs the {$permission} permission.";
}
/**
* All records of the given type (all types when null) for a hostname (several records form a round-robin set).
*
* @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
{
$hostname = strtolower(rtrim($hostname, '.'));
$response = $this->client($zone->integrationToken)->get(
"https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records",
array_filter(['type' => $type, 'name' => $hostname, 'per_page' => 100]),
);
if ($response->status() === 403) {
throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit'));
}
if (! $response->successful()) {
throw new RuntimeException('Cloudflare DNS records could not be checked.');
}
return collect($response->json('result', []))
->filter(fn ($remote): bool => is_array($remote))
->map(fn (array $remote): array => $this->normalizeRemoteRecord($remote, $hostname, $type ?? ''))
->values()
->all();
}
/**
* Records that block a new record of the type. A CNAME cannot share its name with A, AAAA, or other CNAME records.
*
* @return array<int, array{id: string, type: string, name: string, content: string, proxied: bool|null, ttl: int|null, comment: string|null}>
*/
private function conflictingRecords(DnsProviderZone $zone, string $hostname, string $type): array
{
if ($type !== 'CNAME') {
return $this->findRecords($zone, $hostname, $type);
}
return array_values(array_filter(
$this->findRecords($zone, $hostname, null),
fn (array $remote): bool => in_array($remote['type'], ['A', 'AAAA', 'CNAME'], true),
));
}
/**
* Creates the record with Coolify's ownership comment, or references an existing record that already has the wanted content.
* Existing records that Coolify did not create are only referenced and never marked as owned.
* Runs under the hostname lock and always checks the provider again, so a create that waited for a release
* creates the record again when the release deleted it.
*
* @param array<int, Model> $additionalResources Other resources that use the hostname and also reference the record.
*
* @throws LockTimeoutException when another change for the hostname does not finish in time
*/
public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null, array $additionalResources = [], ?bool $proxied = null): ManagedDnsRecord
{
$hostname = strtolower(rtrim($hostname, '.'));
return $this->hostnameLock->run(
(int) $zone->integrationToken->team_id,
$hostname,
function () use ($zone, $hostname, $content, $resource, $additionalResources, $proxied): ManagedDnsRecord {
$record = $this->createRecordLocked($zone, $hostname, $content, $resource, $proxied);
foreach ($additionalResources as $additionalResource) {
$record->addReference($additionalResource);
}
return $record;
},
);
}
private function createRecordLocked(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource, ?bool $proxied): ManagedDnsRecord
{
$type = $this->recordType($content);
$remoteRecords = $this->conflictingRecords($zone, $hostname, $type);
$matching = collect($remoteRecords)->first(fn (array $remote): bool => $remote['type'] === $type && DnsRecordHints::sameAddress($remote['content'], $content));
if ($matching !== null) {
if ($matching['id'] === '') {
throw new RuntimeException('Cloudflare DNS records could not be checked.');
}
$record = $this->trackExistingRecord($zone, $matching, $type, $hostname, $content, $resource);
$this->auditDnsRecord('adopted', $zone, $hostname, $resource);
return $record;
}
if (count($remoteRecords) > 1) {
throw new RuntimeException("Several DNS records already exist for {$hostname}. Update them in Cloudflare.");
}
if (count($remoteRecords) === 1) {
throw new DnsRecordConflictException($remoteRecords[0]['id'], $remoteRecords[0]['content'], $content);
}
$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' => $proxied ?? $zone->integrationToken->proxiedDnsEnabled(),
'comment' => ManagedDnsRecord::ownershipCommentFor($uuid),
]);
if ($response->status() === 403) {
throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit'));
}
if (! $response->successful() || ! is_string($response->json('result.id'))) {
throw new RuntimeException('Cloudflare could not create the DNS record.');
}
$record = ManagedDnsRecord::query()->create([
'uuid' => $uuid, 'team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id,
'dns_provider_zone_id' => $zone->id, 'provider_record_id' => $response->json('result.id'),
'type' => $type, 'name' => $hostname, 'content' => $content, 'owned' => true,
]);
if ($resource !== null) {
$record->addReference($resource);
}
$this->auditDnsRecord('created', $zone, $hostname, $resource);
return $record;
}
/**
* Points an existing (conflicting) record at the new content after explicit user confirmation.
* Only the content (and the type, when it differs) changes: TTL, comment and other settings stay as they are.
* The proxy status changes only when $proxied is set.
*
* @param array<int, Model> $additionalResources Other resources that use the hostname and also reference the record.
*
* @throws LockTimeoutException when another change for the hostname does not finish in time
*/
public function replaceRecord(
DnsProviderZone $zone,
string $recordId,
string $hostname,
string $content,
?Model $resource = null,
?string $expectedCurrent = null,
array $additionalResources = [],
bool $proxied = false,
): ManagedDnsRecord {
$hostname = strtolower(rtrim($hostname, '.'));
return $this->hostnameLock->run(
(int) $zone->integrationToken->team_id,
$hostname,
function () use ($zone, $recordId, $hostname, $content, $resource, $expectedCurrent, $additionalResources, $proxied): ManagedDnsRecord {
$record = $this->replaceRecordLocked($zone, $recordId, $hostname, $content, $resource, $expectedCurrent, $proxied);
foreach ($additionalResources as $additionalResource) {
$record->addReference($additionalResource);
}
return $record;
},
);
}
private function replaceRecordLocked(
DnsProviderZone $zone,
string $recordId,
string $hostname,
string $content,
?Model $resource,
?string $expectedCurrent,
bool $proxied,
): ManagedDnsRecord {
$type = $this->recordType($content);
$remoteRecords = $this->conflictingRecords($zone, $hostname, $type);
if (count($remoteRecords) > 1) {
throw new RuntimeException("Several DNS records already exist for {$hostname}. Update them in Cloudflare.");
}
$remote = $remoteRecords[0] ?? null;
if ($remote === null
|| $remote['id'] === ''
|| $remote['id'] !== $recordId
|| ($expectedCurrent !== null && ! DnsRecordHints::sameAddress($remote['content'], $expectedCurrent))
|| $remote['name'] !== $hostname) {
throw new RuntimeException('The DNS conflict is no longer available. Check the record again.');
}
$changes = ['content' => $content];
if ($remote['type'] !== $type) {
$changes['type'] = $type;
}
if ($proxied) {
$changes['proxied'] = true;
}
$response = $this->client($zone->integrationToken)->patch(
"https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records/{$remote['id']}",
$changes,
);
if ($response->status() === 403) {
throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit'));
}
if (! $response->successful()) {
throw new RuntimeException('Cloudflare could not replace the conflicting DNS record.');
}
$record = $this->trackExistingRecord($zone, $remote, $type, $hostname, $content, $resource);
$this->auditDnsRecord('replaced', $zone, $hostname, $resource);
return $record;
}
/**
* Deletes the provider record only when Coolify created it and it still carries Coolify's ownership comment and value.
* Removes the local row when the provider record is gone.
*/
public function deleteRecord(ManagedDnsRecord $record, ?Model $resource = null): ManagedDnsDeletionResult
{
if (! $record->owned) {
return ManagedDnsDeletionResult::NotOwned;
}
$record->loadMissing(['zone', 'integrationToken']);
$url = "https://api.cloudflare.com/client/v4/zones/{$record->zone->provider_zone_id}/dns_records/{$record->provider_record_id}";
try {
$response = $this->client($record->integrationToken)->get($url);
if ($response->status() === 404) {
return $this->forgetDeletedRecord($record, ManagedDnsDeletionResult::AlreadyGone, $resource);
}
$remote = $response->json('result');
if (! $response->successful() || ! is_array($remote)) {
return ManagedDnsDeletionResult::Failed;
}
if (($remote['type'] ?? null) !== $record->type
|| strtolower((string) ($remote['name'] ?? '')) !== $record->name
|| ! DnsRecordHints::sameAddress(is_string($remote['content'] ?? null) ? $remote['content'] : null, $record->content)
|| ($remote['comment'] ?? null) !== $record->ownershipComment()) {
return ManagedDnsDeletionResult::ChangedExternally;
}
$deleteResponse = $this->client($record->integrationToken)->delete($url);
if ($deleteResponse->status() === 404) {
return $this->forgetDeletedRecord($record, ManagedDnsDeletionResult::AlreadyGone, $resource);
}
if (! $deleteResponse->successful()) {
return ManagedDnsDeletionResult::Failed;
}
} catch (Throwable) {
return ManagedDnsDeletionResult::Failed;
}
return $this->forgetDeletedRecord($record, ManagedDnsDeletionResult::Deleted, $resource);
}
/**
* Takes ownership of a record that another Coolify instance created, after a server transfer. The provider
* comment changes from $previousComment to the ownership comment of this instance only when the record still
* has the expected type, name, value and comment. Never throws; returns false when the record was not adopted.
*/
public function adoptRecord(ManagedDnsRecord $record, string $previousComment): bool
{
try {
$record->loadMissing(['zone', 'integrationToken']);
if ($record->zone === null || $record->integrationToken?->provider !== 'cloudflare') {
return false;
}
return $this->hostnameLock->run(
(int) $record->integrationToken->team_id,
$record->name,
fn (): bool => $this->adoptRecordLocked($record, $previousComment),
);
} catch (Throwable) {
return false;
}
}
/**
* Takes a record back after a take back, when another Coolify instance may have adopted it meanwhile. The
* provider comment must be an ownership comment for this record uuid, from this or any other instance.
* Never throws; returns false when the record was not taken back.
*/
public function reclaimRecord(ManagedDnsRecord $record): bool
{
try {
$record->loadMissing(['zone', 'integrationToken']);
if ($record->zone === null || $record->integrationToken?->provider !== 'cloudflare') {
return false;
}
return $this->hostnameLock->run(
(int) $record->integrationToken->team_id,
$record->name,
function () use ($record): bool {
$url = "https://api.cloudflare.com/client/v4/zones/{$record->zone->provider_zone_id}/dns_records/{$record->provider_record_id}";
$comment = $this->client($record->integrationToken)->get($url)->json('result.comment');
$pattern = '/^'.preg_quote(ManagedDnsRecord::OWNERSHIP_COMMENT_PREFIX, '/').' \\S+\\/'.preg_quote($record->uuid, '/').'$/';
if (! is_string($comment) || preg_match($pattern, $comment) !== 1) {
return false;
}
return $comment === $record->ownershipComment() || $this->adoptRecordLocked($record, $comment);
},
);
} catch (Throwable) {
return false;
}
}
private function adoptRecordLocked(ManagedDnsRecord $record, string $previousComment): bool
{
$url = "https://api.cloudflare.com/client/v4/zones/{$record->zone->provider_zone_id}/dns_records/{$record->provider_record_id}";
$response = $this->client($record->integrationToken)->get($url);
$remote = $response->json('result');
if (! $response->successful() || ! is_array($remote)) {
return false;
}
if (($remote['type'] ?? null) !== $record->type
|| strtolower((string) ($remote['name'] ?? '')) !== $record->name
|| ! DnsRecordHints::sameAddress(is_string($remote['content'] ?? null) ? $remote['content'] : null, $record->content)
|| ($remote['comment'] ?? null) !== $previousComment) {
return false;
}
$patchResponse = $this->client($record->integrationToken)->patch($url, ['comment' => $record->ownershipComment()]);
if (! $patchResponse->successful()) {
return false;
}
$this->auditDnsRecord('ownership_adopted', $record->zone, $record->name, null);
return true;
}
private function forgetDeletedRecord(ManagedDnsRecord $record, ManagedDnsDeletionResult $result, ?Model $resource): ManagedDnsDeletionResult
{
$record->delete();
$this->auditDnsRecord($result === ManagedDnsDeletionResult::Deleted ? 'deleted' : 'already_deleted', $record->zone, $record->name, $resource);
return $result;
}
/**
* Tracks a record that already exists in the provider. New rows are never owned; an owned row whose
* provider comment no longer matches loses its ownership.
*
* @param array{id: string, comment: string|null} $remote
*/
private function trackExistingRecord(DnsProviderZone $zone, array $remote, string $type, string $name, string $content, ?Model $resource): ManagedDnsRecord
{
$record = ManagedDnsRecord::query()->firstOrNew(['dns_provider_zone_id' => $zone->id, 'provider_record_id' => $remote['id']]);
$record->fill([
'team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id,
'type' => $type, 'name' => $name, 'content' => $content,
]);
if (! $record->exists || $remote['comment'] !== $record->ownershipComment()) {
$record->owned = false;
}
$record->save();
if ($resource !== null) {
$record->addReference($resource);
}
return $record;
}
/**
* @param array<string, mixed> $remote
* @return array{id: string, type: string, name: string, content: string, proxied: bool|null, ttl: int|null, comment: string|null}
*/
private function normalizeRemoteRecord(array $remote, string $hostname, string $type): array
{
return [
'id' => (string) ($remote['id'] ?? ''),
'type' => (string) ($remote['type'] ?? $type),
'name' => strtolower((string) ($remote['name'] ?? $hostname)),
'content' => (string) ($remote['content'] ?? ''),
'proxied' => isset($remote['proxied']) ? (bool) $remote['proxied'] : null,
'ttl' => isset($remote['ttl']) ? (int) $remote['ttl'] : null,
'comment' => isset($remote['comment']) && is_string($remote['comment']) ? $remote['comment'] : null,
];
}
private function recordType(string $content): string
{
return match (true) {
filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false => 'AAAA',
filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false => 'A',
default => 'CNAME',
};
}
private function auditDnsRecord(string $action, DnsProviderZone $zone, string $hostname, ?Model $resource): void
{
$resourceType = $resource ? str(class_basename($resource))->snake()->value() : 'dns_record';
$source = auth()->check() ? 'ui' : 'system';
auditLog("{$source}.dns_record.{$action}", [
'team_id' => $zone->integrationToken->team_id,
'resource' => $resourceType,
"{$resourceType}_uuid" => $resource?->getAttribute('uuid'),
"{$resourceType}_name" => $resource?->getAttribute('name'),
'hostname' => $hostname,
'provider' => 'cloudflare',
'zone' => $zone->name,
]);
}
private function client(IntegrationToken $token): PendingRequest
{
return Http::withToken($token->token)->acceptJson()->connectTimeout(5)->timeout(10);
}
}