mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
259 lines
11 KiB
PHP
259 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Dns;
|
|
|
|
use App\Enums\ManagedDnsDeletionResult;
|
|
use App\Exceptions\DnsRecordConflictException;
|
|
use App\Jobs\ConfigureDnsRecordJob;
|
|
use App\Models\Application;
|
|
use App\Models\DnsProviderZone;
|
|
use App\Models\ManagedDnsRecord;
|
|
use App\Models\Service;
|
|
use App\Support\DnsRecordHints;
|
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
use Illuminate\Validation\ValidationException;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Managed DNS records for the saved domains of an application or a service (the DNS API).
|
|
* A service domain belongs to one of its service applications; that application references the record.
|
|
* Only zones of the resource's team are used, and records only point to the public addresses of the resource's server.
|
|
*/
|
|
class ResourceDnsRecords
|
|
{
|
|
public function __construct(
|
|
private CloudflareDnsProvider $provider,
|
|
private ManagedDnsRecordCleanup $cleanup,
|
|
) {}
|
|
|
|
/**
|
|
* Hostnames of the resource with the applications that use them (the application, or service applications).
|
|
*
|
|
* @return array<string, array<int, Model>>
|
|
*/
|
|
public function hostnameUsers(Application|Service $resource): array
|
|
{
|
|
$users = [];
|
|
foreach ($this->domainModels($resource) as $model) {
|
|
foreach ($this->cleanup->hostnamesOf($model) as $hostname) {
|
|
$users[$hostname][] = $model;
|
|
}
|
|
}
|
|
ksort($users);
|
|
|
|
return $users;
|
|
}
|
|
|
|
public function addresses(Application|Service $resource): ServerDnsAddresses
|
|
{
|
|
return ServerDnsAddresses::forServer($resource instanceof Service ? $resource->server : $resource->destination?->server);
|
|
}
|
|
|
|
/**
|
|
* DNS providers and managed records for every hostname of the resource. Reads only the database.
|
|
*
|
|
* @return array{default_address: ?string, public_addresses: array<int, string>, hostnames: array<int, array<string, mixed>>}
|
|
*/
|
|
public function status(Application|Service $resource): array
|
|
{
|
|
$teamId = $this->teamId($resource);
|
|
$hostnames = array_keys($this->hostnameUsers($resource));
|
|
$records = ManagedDnsRecord::query()->with('integrationToken')
|
|
->where('team_id', $teamId)->whereIn('name', $hostnames)->get()->groupBy('name');
|
|
$addresses = $this->addresses($resource);
|
|
|
|
return [
|
|
'default_address' => $addresses->recordContent(),
|
|
'public_addresses' => $addresses->publicAddresses(),
|
|
'hostnames' => collect($hostnames)->map(fn (string $hostname): array => [
|
|
'hostname' => $hostname,
|
|
'providers' => $this->provider->findZones($teamId, $hostname)->map(fn (DnsProviderZone $zone): array => [
|
|
'integration_token_uuid' => $zone->integrationToken->uuid,
|
|
'integration_token_name' => $zone->integrationToken->name,
|
|
'zone' => $zone->name,
|
|
'automatic_dns' => $zone->integrationToken->automaticDnsEnabled(),
|
|
'proxied_dns' => $zone->integrationToken->proxiedDnsEnabled(),
|
|
])->values()->all(),
|
|
'records' => $records->get($hostname, collect())->map(fn (ManagedDnsRecord $record): array => $this->recordData($record))->values()->all(),
|
|
])->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @throws ValidationException when the hostname, provider, or content is not valid for the resource
|
|
* @throws DnsRecordConflictException when another record already uses the hostname
|
|
* @throws LockTimeoutException when another DNS change for the hostname is in progress
|
|
* @throws RuntimeException when Cloudflare rejects the change
|
|
*/
|
|
public function create(Application|Service $resource, string $hostname, ?string $integrationTokenUuid, ?string $content): ManagedDnsRecord
|
|
{
|
|
[$hostname, $users, $zone, $content] = $this->prepare($resource, $hostname, $integrationTokenUuid, $content);
|
|
|
|
return $this->provider->createRecord($zone, $hostname, $content, $users[0], array_slice($users, 1));
|
|
}
|
|
|
|
/**
|
|
* Points the conflicting record from a create response at the server. The record must still have the ID and
|
|
* the content that the conflict reported.
|
|
*
|
|
* @throws ValidationException|LockTimeoutException|RuntimeException
|
|
*/
|
|
public function replace(
|
|
Application|Service $resource,
|
|
string $hostname,
|
|
?string $integrationTokenUuid,
|
|
string $recordId,
|
|
string $currentContent,
|
|
?string $content,
|
|
): ManagedDnsRecord {
|
|
[$hostname, $users, $zone, $content] = $this->prepare($resource, $hostname, $integrationTokenUuid, $content);
|
|
|
|
return $this->provider->replaceRecord($zone, $recordId, $hostname, $content, $users[0], $currentContent, array_slice($users, 1));
|
|
}
|
|
|
|
/**
|
|
* Releases the resource's references to the managed record of a hostname that the resource no longer uses.
|
|
* The provider record is deleted only when $deleteRecord is set, Coolify created it, and nothing else uses the hostname.
|
|
* Null means the reference was released and the provider record was kept.
|
|
*
|
|
* @throws ValidationException when the resource still uses the hostname
|
|
* @throws ModelNotFoundException when no managed record of the hostname references the resource
|
|
*/
|
|
public function release(Application|Service $resource, string $hostname, bool $deleteRecord): ?ManagedDnsDeletionResult
|
|
{
|
|
$hostname = $this->normalizeHostname($hostname);
|
|
if (isset($this->hostnameUsers($resource)[$hostname])) {
|
|
throw ValidationException::withMessages([
|
|
'hostname' => "The resource still uses {$hostname}. Remove the domain first; its DNS record is then released.",
|
|
]);
|
|
}
|
|
|
|
$teamId = $this->teamId($resource);
|
|
$models = collect($this->domainModels($resource))->filter(fn (Model $model): bool => ManagedDnsRecord::query()
|
|
->where('team_id', $teamId)->where('name', $hostname)
|
|
->whereHas('references', fn (Builder $query) => $query
|
|
->where('resource_type', $model->getMorphClass())->where('resource_id', $model->getKey()))
|
|
->exists());
|
|
if ($models->isEmpty()) {
|
|
throw (new ModelNotFoundException)->setModel(ManagedDnsRecord::class);
|
|
}
|
|
|
|
$results = $models->map(fn (Model $model) => $this->cleanup->releaseHostname($model, $hostname, $teamId, $deleteRecord))->filter();
|
|
|
|
return $results->first(fn (ManagedDnsDeletionResult $result): bool => $result->shouldRetry())
|
|
?? $results->first(fn (ManagedDnsDeletionResult $result): bool => $result === ManagedDnsDeletionResult::ChangedExternally)
|
|
?? $results->first();
|
|
}
|
|
|
|
/**
|
|
* Queues a DNS record for each hostname added since $previousHostnames, like the Domains page does, when exactly
|
|
* one zone of the team manages the hostname and its token has automatic DNS on.
|
|
*
|
|
* @param array<int, string> $previousHostnames
|
|
* @return array<int, string> the hostnames that were queued
|
|
*/
|
|
public function queueAutomaticRecords(Application|Service $resource, array $previousHostnames): array
|
|
{
|
|
$addedUsers = array_diff_key($this->hostnameUsers($resource), array_flip($previousHostnames));
|
|
if ($addedUsers === []) {
|
|
return [];
|
|
}
|
|
|
|
$teamId = $this->teamId($resource);
|
|
$automaticZones = collect($addedUsers)
|
|
->map(fn (array $users, string $hostname) => $this->provider->findZones($teamId, $hostname))
|
|
->filter(fn ($zones): bool => $zones->count() === 1 && $zones->first()->integrationToken->automaticDnsEnabled())
|
|
->map(fn ($zones): DnsProviderZone => $zones->first());
|
|
$content = $automaticZones->isEmpty() ? null : $this->addresses($resource)->recordContent();
|
|
if ($content === null) {
|
|
return [];
|
|
}
|
|
|
|
foreach ($automaticZones as $hostname => $zone) {
|
|
$user = $addedUsers[$hostname][0];
|
|
ConfigureDnsRecordJob::dispatch($teamId, $zone->id, $user->getMorphClass(), $user->getKey(), $hostname, $content)->afterCommit();
|
|
}
|
|
|
|
return $automaticZones->keys()->all();
|
|
}
|
|
|
|
/**
|
|
* @return array{uuid: string, hostname: string, type: string, address: string, owned: bool, integration_token_uuid: ?string}
|
|
*/
|
|
public function recordData(ManagedDnsRecord $record): array
|
|
{
|
|
return [
|
|
'uuid' => $record->uuid,
|
|
'hostname' => $record->name,
|
|
'type' => $record->type,
|
|
'address' => $record->content,
|
|
'owned' => $record->owned,
|
|
'integration_token_uuid' => $record->integrationToken?->uuid,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{0: string, 1: array<int, Model>, 2: DnsProviderZone, 3: string}
|
|
*
|
|
* @throws ValidationException
|
|
*/
|
|
private function prepare(Application|Service $resource, string $hostname, ?string $integrationTokenUuid, ?string $content): array
|
|
{
|
|
$hostname = $this->normalizeHostname($hostname);
|
|
$users = $this->hostnameUsers($resource)[$hostname] ?? null;
|
|
if ($users === null) {
|
|
throw ValidationException::withMessages(['hostname' => "{$hostname} is not a domain of this resource."]);
|
|
}
|
|
|
|
$addresses = $this->addresses($resource);
|
|
if ($addresses->publicAddresses() === []) {
|
|
throw ValidationException::withMessages(['address' => DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE]);
|
|
}
|
|
$recordContent = $addresses->recordContent($content);
|
|
if ($recordContent === null) {
|
|
throw ValidationException::withMessages([
|
|
'address' => 'The address must be a public address of the server: '.implode(', ', $addresses->publicAddresses()).'.',
|
|
]);
|
|
}
|
|
|
|
$zones = $this->provider->findZones($this->teamId($resource), $hostname);
|
|
if ($integrationTokenUuid !== null) {
|
|
$zones = $zones->filter(fn (DnsProviderZone $zone): bool => $zone->integrationToken->uuid === $integrationTokenUuid)->values();
|
|
}
|
|
if ($zones->isEmpty()) {
|
|
throw ValidationException::withMessages(['integration_token_uuid' => "No connected DNS provider of the team manages {$hostname}."]);
|
|
}
|
|
if ($zones->count() > 1) {
|
|
throw ValidationException::withMessages([
|
|
'integration_token_uuid' => "Several DNS providers manage {$hostname}. Set integration_token_uuid to one of: "
|
|
.$zones->map(fn (DnsProviderZone $zone): string => $zone->integrationToken->uuid)->implode(', ').'.',
|
|
]);
|
|
}
|
|
|
|
return [$hostname, $users, $zones->first(), $recordContent];
|
|
}
|
|
|
|
/**
|
|
* Models that hold the domains: the application, or the applications of a service.
|
|
*
|
|
* @return array<int, Model>
|
|
*/
|
|
private function domainModels(Application|Service $resource): array
|
|
{
|
|
return $resource instanceof Service ? $resource->applications()->get()->all() : [$resource];
|
|
}
|
|
|
|
private function teamId(Application|Service $resource): int
|
|
{
|
|
return $resource->team()?->id ?? throw new RuntimeException('The resource has no team.');
|
|
}
|
|
|
|
private function normalizeHostname(string $hostname): string
|
|
{
|
|
return strtolower(rtrim(trim($hostname), '.'));
|
|
}
|
|
}
|