feat(domains): add asynchronous DNS validation
Add queued DNS checks with polling, status indicators, and notifications for application and service domains. Improve volume backup target labels and names.
This commit is contained in:
parent
e82843ac39
commit
eb57a691ca
14 changed files with 1016 additions and 130 deletions
142
app/Actions/Shared/CheckDomainDns.php
Normal file
142
app/Actions/Shared/CheckDomainDns.php
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Shared;
|
||||
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use PurplePixie\PhpDns\DNSQuery;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class CheckDomainDns
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
* @return array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}>
|
||||
*/
|
||||
public function handle(
|
||||
array $entries,
|
||||
?Server $server,
|
||||
?string $expectedIp,
|
||||
bool $skipForMultipleServers = false,
|
||||
int $timeoutSeconds = 5,
|
||||
): array {
|
||||
if (! data_get(instanceSettings(), 'is_dns_validation_enabled')) {
|
||||
return $this->sameResultForAll($entries, 'skipped', 'DNS validation is disabled in instance settings.', $expectedIp);
|
||||
}
|
||||
|
||||
if (! $server) {
|
||||
return $this->sameResultForAll($entries, 'skipped', 'No server available for DNS validation.', null);
|
||||
}
|
||||
|
||||
if ($skipForMultipleServers) {
|
||||
return $this->sameResultForAll($entries, 'skipped', 'DNS check skipped for multi-server applications.', $expectedIp);
|
||||
}
|
||||
|
||||
$deadline = hrtime(true) + ($timeoutSeconds * 1_000_000_000);
|
||||
$dnsServers = str(data_get(instanceSettings(), 'custom_dns_servers'))
|
||||
->explode(',')
|
||||
->map(fn ($dnsServer) => trim((string) $dnsServer))
|
||||
->filter()
|
||||
->values();
|
||||
$results = [];
|
||||
|
||||
foreach ($entries as $key => $url) {
|
||||
$results[$key] = $this->check($url, $server, $expectedIp, $dnsServers->all(), $deadline);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $dnsServers
|
||||
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
|
||||
*/
|
||||
private function check(string $url, Server $server, ?string $expectedIp, array $dnsServers, int $deadline): array
|
||||
{
|
||||
try {
|
||||
$host = Url::fromString($url)->getHost();
|
||||
} catch (\Throwable) {
|
||||
return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp);
|
||||
}
|
||||
if (str($host)->contains('sslip.io')) {
|
||||
return $this->result('ok', 'DNS looks correct.', $expectedIp);
|
||||
}
|
||||
|
||||
$type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
|
||||
|
||||
foreach ($dnsServers as $dnsServer) {
|
||||
$remainingNanoseconds = $deadline - hrtime(true);
|
||||
if ($remainingNanoseconds < 1_000_000_000) {
|
||||
return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp);
|
||||
}
|
||||
|
||||
try {
|
||||
$query = app()->make(DNSQuery::class, [
|
||||
'server' => $dnsServer,
|
||||
'port' => 53,
|
||||
'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)),
|
||||
]);
|
||||
$records = $query->query($host, $type);
|
||||
|
||||
if ($records === false || $query->hasError()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
if ($record->getType() !== $type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) {
|
||||
return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp);
|
||||
}
|
||||
|
||||
private function successMessage(Server $server, ?string $expectedIp): string
|
||||
{
|
||||
if (
|
||||
filled($expectedIp)
|
||||
&& filled($server->ip)
|
||||
&& $server->ip !== $expectedIp
|
||||
&& filter_var($server->ip, FILTER_VALIDATE_IP) === false
|
||||
) {
|
||||
return "DNS points to {$expectedIp} ({$server->ip}) (or Cloudflare).";
|
||||
}
|
||||
|
||||
return $expectedIp ? "DNS points to {$expectedIp} (or Cloudflare)." : 'DNS looks correct.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
|
||||
*/
|
||||
private function result(string $status, string $message, ?string $expectedIp): array
|
||||
{
|
||||
return [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'expected_ip' => $expectedIp,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
* @return array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}>
|
||||
*/
|
||||
private function sameResultForAll(array $entries, string $status, string $message, ?string $expectedIp): array
|
||||
{
|
||||
$result = $this->result($status, $message, $expectedIp);
|
||||
|
||||
return array_fill_keys(array_keys($entries), $result);
|
||||
}
|
||||
}
|
||||
90
app/Jobs/CheckDomainDnsJob.php
Normal file
90
app/Jobs/CheckDomainDnsJob.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(
|
||||
public Application|ServiceApplication $resource,
|
||||
public string $statusKey,
|
||||
public string $url,
|
||||
public ?Server $server,
|
||||
public ?string $expectedIp,
|
||||
public string $checkId,
|
||||
public bool $skipForMultipleServers = false,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->persistResults(CheckDomainDns::run(
|
||||
[$this->statusKey => $this->url],
|
||||
$this->server,
|
||||
$this->expectedIp,
|
||||
$this->skipForMultipleServers,
|
||||
));
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
$this->persistResults([
|
||||
$this->statusKey => $this->status('failed', 'Could not validate DNS for this domain.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
|
||||
*/
|
||||
private function status(string $status, string $message): array
|
||||
{
|
||||
return [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'expected_ip' => $this->expectedIp,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}> $results
|
||||
*/
|
||||
private function persistResults(array $results): void
|
||||
{
|
||||
DB::transaction(function () use ($results): void {
|
||||
$resource = $this->resource::query()->lockForUpdate()->find($this->resource->getKey());
|
||||
if (! $resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statuses = $resource->domain_dns_statuses ?? [];
|
||||
|
||||
foreach ($results as $key => $result) {
|
||||
if (($statuses[$key]['status'] ?? null) !== 'checking' || ($statuses[$key]['check_id'] ?? null) !== $this->checkId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statuses[$key] = $result;
|
||||
}
|
||||
|
||||
$resource->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$resource->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Application;
|
||||
|
|
@ -10,6 +12,7 @@
|
|||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class Domains extends Component
|
||||
|
|
@ -141,6 +144,39 @@ public function refreshDomains(): void
|
|||
$this->loadDomainState();
|
||||
}
|
||||
|
||||
public function pollDnsChecks(): void
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
|
||||
$checkingRows = collect($this->domainRows)
|
||||
->where('dns_status', 'checking')
|
||||
->values();
|
||||
|
||||
$this->refreshDomains();
|
||||
|
||||
foreach ($checkingRows as $checkingRow) {
|
||||
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
|
||||
&& ($row['service'] ?? null) === ($checkingRow['service'] ?? null));
|
||||
|
||||
if (! is_array($row) || $row['dns_status'] === 'checking') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
|
||||
}
|
||||
}
|
||||
|
||||
protected function dispatchDnsCheckNotification(string $url, string $status): void
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST) ?: $url;
|
||||
|
||||
match ($status) {
|
||||
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
|
||||
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
|
||||
default => $this->dispatch('info', "DNS check skipped for {$host}."),
|
||||
};
|
||||
}
|
||||
|
||||
public function toggleNoindexDomain(string $domain, string|bool $indexing): void
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
|
@ -464,6 +500,7 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto
|
|||
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
|
||||
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
|
||||
'checked_at' => data_get($entry, 'checked_at'),
|
||||
'check_id' => data_get($entry, 'check_id'),
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
|
|
@ -478,6 +515,7 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto
|
|||
'dns_message' => 'Not checked yet.',
|
||||
'expected_ip' => $this->serverIp,
|
||||
'checked_at' => null,
|
||||
'check_id' => null,
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
|
|
@ -533,6 +571,8 @@ public function checkAllDns(): void
|
|||
|| ! $server
|
||||
|| $this->application->additional_servers->count() > 0;
|
||||
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if ($skipDns) {
|
||||
$reason = ! $this->dnsValidationEnabled
|
||||
|
|
@ -548,7 +588,11 @@ public function checkAllDns(): void
|
|||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $row['url'], $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistDomainDnsStatuses();
|
||||
|
|
@ -575,45 +619,50 @@ public function checkDomainDns(int $index): void
|
|||
return;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server);
|
||||
$this->applyDnsStatus($index, $server);
|
||||
$this->persistDomainDnsStatuses();
|
||||
}
|
||||
|
||||
protected function applyDnsStatus(int $index, string $url, Server $server): void
|
||||
protected function applyDnsStatus(int $index, Server $server): void
|
||||
{
|
||||
$target = $this->dnsTargetLabel();
|
||||
$this->applyDnsStatuses([$index], $server);
|
||||
}
|
||||
|
||||
try {
|
||||
$isValid = validateDNSEntry($url, $server);
|
||||
if ($isValid) {
|
||||
$this->domainRows[$index]['dns_status'] = 'ok';
|
||||
$this->domainRows[$index]['dns_message'] = $target
|
||||
? "DNS points to {$target} (or Cloudflare)."
|
||||
: 'DNS looks correct.';
|
||||
} else {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
/**
|
||||
* @param array<int, int> $indexes
|
||||
*/
|
||||
protected function applyDnsStatuses(array $indexes, Server $server): void
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($indexes as $index) {
|
||||
$entries[(string) $index] = $this->domainRows[$index]['url'];
|
||||
}
|
||||
|
||||
$results = CheckDomainDns::run($entries, $server, $this->serverIp);
|
||||
|
||||
foreach ($results as $index => $result) {
|
||||
$index = (int) $index;
|
||||
$this->domainRows[$index]['dns_status'] = $result['status'];
|
||||
$this->domainRows[$index]['dns_message'] = $result['message'];
|
||||
|
||||
// Keep suggested-row copy short after DNS checks (no role badge).
|
||||
if ($this->domainRows[$index]['is_suggested'] ?? false) {
|
||||
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
|
||||
$serviceName = $this->domainRows[$index]['service'] ?? null;
|
||||
$meta = $this->suggestedDomainMeta(
|
||||
$isWww,
|
||||
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
|
||||
);
|
||||
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
|
||||
$this->domainRows[$index]['suggestion_label'] = null;
|
||||
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.';
|
||||
}
|
||||
|
||||
// Keep suggested-row copy short after DNS checks (no role badge).
|
||||
if ($this->domainRows[$index]['is_suggested'] ?? false) {
|
||||
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
|
||||
$serviceName = $this->domainRows[$index]['service'] ?? null;
|
||||
$meta = $this->suggestedDomainMeta(
|
||||
$isWww,
|
||||
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
|
||||
);
|
||||
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
|
||||
$this->domainRows[$index]['suggestion_label'] = null;
|
||||
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
|
||||
$this->domainRows[$index]['expected_ip'] = $result['expected_ip'];
|
||||
$this->domainRows[$index]['checked_at'] = $result['checked_at'];
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['expected_ip'] = $this->serverIp;
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -647,11 +696,34 @@ protected function persistDomainDnsStatuses(): void
|
|||
'message' => (string) ($row['dns_message'] ?? ''),
|
||||
'expected_ip' => $row['expected_ip'] ?? $this->serverIp,
|
||||
'checked_at' => $row['checked_at'] ?? now()->toIso8601String(),
|
||||
'check_id' => $row['check_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
DB::transaction(function () use (&$statuses): void {
|
||||
$application = Application::query()->lockForUpdate()->findOrFail($this->application->id);
|
||||
$storedStatuses = $application->domain_dns_statuses ?? [];
|
||||
|
||||
foreach ($statuses as $key => $status) {
|
||||
$localCheckId = $status['check_id'] ?? null;
|
||||
$storedCheckId = $storedStatuses[$key]['check_id'] ?? null;
|
||||
|
||||
if ($storedCheckId !== null && $localCheckId !== $storedCheckId) {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$application->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$application->save();
|
||||
});
|
||||
|
||||
$this->application->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$this->application->save();
|
||||
}
|
||||
|
||||
protected function pruneDomainDnsStatusesToCurrentDomains(): void
|
||||
|
|
@ -804,16 +876,6 @@ public function addDomain(): void
|
|||
}
|
||||
}
|
||||
|
||||
if (! $this->forceSaveDns && $this->shouldValidateDnsForAdd()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage($newUrls);
|
||||
if ($dnsFailure !== null) {
|
||||
$this->addDomainDnsFailed = true;
|
||||
$this->addDomainDnsMessage = $dnsFailure;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
|
||||
$this->pendingAction = 'add';
|
||||
if (! $this->saveDomainList($merged, $this->newDomainService)) {
|
||||
|
|
@ -825,14 +887,110 @@ public function addDomain(): void
|
|||
$serviceForCheck = $this->newDomainService;
|
||||
$this->resetAddDomainForm();
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('success', 'Domain added.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck);
|
||||
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
|
||||
$dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck))
|
||||
->map(fn (string $url, string $statusKey) => [
|
||||
'status_key' => $statusKey,
|
||||
'url' => $url,
|
||||
'check_id' => new_public_id(),
|
||||
]);
|
||||
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
$this->markUrlsAsChecking([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']);
|
||||
}
|
||||
$this->persistDomainDnsStatuses();
|
||||
|
||||
$failedDnsChecks = 0;
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
try {
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$this->application,
|
||||
$dnsCheck['status_key'],
|
||||
$dnsCheck['url'],
|
||||
$this->application->destination?->server,
|
||||
$this->serverIp,
|
||||
$dnsCheck['check_id'],
|
||||
$this->application->additional_servers->count() > 0,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$failedDnsChecks++;
|
||||
$this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($failedDnsChecks > 0) {
|
||||
$this->persistDomainDnsStatuses();
|
||||
$this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.');
|
||||
}
|
||||
|
||||
$this->dispatch('success', $failedDnsChecks === $dnsChecks->count()
|
||||
? 'Domain added.'
|
||||
: 'Domain added. DNS check started.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsAsChecking(array $urls, ?string $service = null, ?string $checkId = null): void
|
||||
{
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($service !== null && ($row['service'] ?? null) !== $service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'checking';
|
||||
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$index]['check_id'] = $checkId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsDnsCheckUnavailable(array $urls, ?string $service = null, ?string $checkId = null): void
|
||||
{
|
||||
$this->markUrlsAsChecking($urls, $service, $checkId);
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($service !== null && ($row['service'] ?? null) !== $service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function dnsEntriesForUrls(array $urls, ?string $service = null): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($urls as $url) {
|
||||
$entries[$this->domainDnsStatusKey($url, $service)] = $url;
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a first-time DNS check for newly added/updated domain URLs and persist results.
|
||||
*
|
||||
|
|
@ -875,7 +1033,11 @@ protected function checkUrlsDns(array $urls, ?string $service = null): void
|
|||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $url, $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistDomainDnsStatuses();
|
||||
|
|
@ -909,15 +1071,11 @@ protected function findDnsFailureMessage(array $urls): ?string
|
|||
return null;
|
||||
}
|
||||
|
||||
$target = $this->dnsTargetLabel() ?? $server->ip;
|
||||
$results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp);
|
||||
|
||||
foreach ($urls as $url) {
|
||||
try {
|
||||
if (! validateDNSEntry($url, $server)) {
|
||||
return dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return 'Could not validate DNS for this domain.';
|
||||
foreach ($results as $result) {
|
||||
if ($result['status'] === 'failed') {
|
||||
return $result['message'];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Server;
|
||||
|
|
@ -131,6 +133,39 @@ public function refreshDomains(): void
|
|||
$this->loadDomainState();
|
||||
}
|
||||
|
||||
public function pollDnsChecks(): void
|
||||
{
|
||||
$this->authorize('view', $this->service);
|
||||
|
||||
$checkingRows = collect($this->domainRows)
|
||||
->where('dns_status', 'checking')
|
||||
->values();
|
||||
|
||||
$this->refreshDomains();
|
||||
|
||||
foreach ($checkingRows as $checkingRow) {
|
||||
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
|
||||
&& (int) $row['service_application_id'] === (int) $checkingRow['service_application_id']);
|
||||
|
||||
if (! is_array($row) || $row['dns_status'] === 'checking') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
|
||||
}
|
||||
}
|
||||
|
||||
protected function dispatchDnsCheckNotification(string $url, string $status): void
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST) ?: $url;
|
||||
|
||||
match ($status) {
|
||||
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
|
||||
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
|
||||
default => $this->dispatch('info', "DNS check skipped for {$host}."),
|
||||
};
|
||||
}
|
||||
|
||||
public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void
|
||||
{
|
||||
$application = $this->service->applications()->findOrFail($serviceApplicationId);
|
||||
|
|
@ -282,6 +317,7 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr
|
|||
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
|
||||
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
|
||||
'checked_at' => data_get($entry, 'checked_at'),
|
||||
'check_id' => data_get($entry, 'check_id'),
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
|
|
@ -298,6 +334,7 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr
|
|||
'dns_message' => 'Not checked yet.',
|
||||
'expected_ip' => $this->serverIp,
|
||||
'checked_at' => null,
|
||||
'check_id' => null,
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
|
|
@ -404,6 +441,8 @@ public function checkAllDns(): void
|
|||
$server = $this->service->server;
|
||||
$skipDns = ! $this->dnsValidationEnabled || ! $server;
|
||||
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if ($skipDns) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
|
|
@ -415,7 +454,11 @@ public function checkAllDns(): void
|
|||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $row['url'], $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
|
|
@ -443,33 +486,37 @@ public function checkDomainDns(int $index): void
|
|||
return;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server);
|
||||
$this->applyDnsStatus($index, $server);
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
}
|
||||
|
||||
protected function applyDnsStatus(int $index, string $url, Server $server): void
|
||||
protected function applyDnsStatus(int $index, Server $server): void
|
||||
{
|
||||
$target = $this->dnsTargetLabel();
|
||||
$this->applyDnsStatuses([$index], $server);
|
||||
}
|
||||
|
||||
try {
|
||||
$isValid = validateDNSEntry($url, $server);
|
||||
if ($isValid) {
|
||||
$this->domainRows[$index]['dns_status'] = 'ok';
|
||||
$this->domainRows[$index]['dns_message'] = $target
|
||||
? "DNS points to {$target} (or Cloudflare)."
|
||||
: 'DNS looks correct.';
|
||||
} else {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.';
|
||||
/**
|
||||
* @param array<int, int> $indexes
|
||||
*/
|
||||
protected function applyDnsStatuses(array $indexes, Server $server): void
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($indexes as $index) {
|
||||
$entries[(string) $index] = $this->domainRows[$index]['url'];
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['expected_ip'] = $this->serverIp;
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
$this->decorateSuggestedDomainAfterDnsCheck($index);
|
||||
$results = CheckDomainDns::run($entries, $server, $this->serverIp);
|
||||
|
||||
foreach ($results as $index => $result) {
|
||||
$index = (int) $index;
|
||||
$this->domainRows[$index]['dns_status'] = $result['status'];
|
||||
$this->domainRows[$index]['dns_message'] = $result['message'];
|
||||
$this->domainRows[$index]['expected_ip'] = $result['expected_ip'];
|
||||
$this->domainRows[$index]['checked_at'] = $result['checked_at'];
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
$this->decorateSuggestedDomainAfterDnsCheck($index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -516,6 +563,7 @@ protected function persistAllDomainDnsStatuses(): void
|
|||
'message' => (string) ($row['dns_message'] ?? ''),
|
||||
'expected_ip' => $row['expected_ip'] ?? $this->serverIp,
|
||||
'checked_at' => $row['checked_at'] ?? now()->toIso8601String(),
|
||||
'check_id' => $row['check_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -528,8 +576,30 @@ protected function persistAllDomainDnsStatuses(): void
|
|||
->all();
|
||||
$statuses = array_intersect_key($statuses, array_flip($currentUrls));
|
||||
|
||||
DB::transaction(function () use ($app, &$statuses): void {
|
||||
$application = ServiceApplication::query()->lockForUpdate()->findOrFail($app->id);
|
||||
$storedStatuses = $application->domain_dns_statuses ?? [];
|
||||
|
||||
foreach ($statuses as $key => $status) {
|
||||
$localCheckId = $status['check_id'] ?? null;
|
||||
$storedCheckId = $storedStatuses[$key]['check_id'] ?? null;
|
||||
|
||||
if ($storedCheckId !== null && $localCheckId !== $storedCheckId) {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$application->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$application->save();
|
||||
});
|
||||
|
||||
$app->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$app->save();
|
||||
}
|
||||
|
||||
$this->service->load('applications');
|
||||
|
|
@ -928,16 +998,6 @@ public function addDomain(): void
|
|||
}
|
||||
}
|
||||
|
||||
if (! $this->forceSaveDns && $this->shouldValidateDns()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage($newUrls);
|
||||
if ($dnsFailure !== null) {
|
||||
$this->addDomainDnsFailed = true;
|
||||
$this->addDomainDnsMessage = $dnsFailure;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
|
||||
$this->pendingAction = 'add';
|
||||
|
||||
|
|
@ -955,14 +1015,93 @@ public function addDomain(): void
|
|||
$this->forceRemovePort = false;
|
||||
$this->pendingAction = null;
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('success', 'Domain added.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id);
|
||||
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
|
||||
$serviceApplicationId = (int) $app->id;
|
||||
$dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [
|
||||
'url' => $url,
|
||||
'check_id' => new_public_id(),
|
||||
]);
|
||||
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
$this->markUrlsAsChecking([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']);
|
||||
}
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
|
||||
$failedDnsChecks = 0;
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
try {
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$app,
|
||||
$dnsCheck['url'],
|
||||
$dnsCheck['url'],
|
||||
$this->service->server,
|
||||
$this->serverIp,
|
||||
$dnsCheck['check_id'],
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$failedDnsChecks++;
|
||||
$this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($failedDnsChecks > 0) {
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
$this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.');
|
||||
}
|
||||
|
||||
$this->dispatch('success', $failedDnsChecks === $dnsChecks->count()
|
||||
? 'Domain added.'
|
||||
: 'Domain added. DNS check started.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsAsChecking(array $urls, int $serviceApplicationId, ?string $checkId = null): void
|
||||
{
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'checking';
|
||||
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$index]['check_id'] = $checkId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsDnsCheckUnavailable(array $urls, int $serviceApplicationId, ?string $checkId = null): void
|
||||
{
|
||||
$this->markUrlsAsChecking($urls, $serviceApplicationId, $checkId);
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
public function startEdit(int $index): void
|
||||
{
|
||||
if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) {
|
||||
|
|
@ -1309,7 +1448,11 @@ protected function checkUrlsDns(array $urls, ?int $serviceApplicationId = null):
|
|||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $url, $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
|
|
@ -1330,15 +1473,11 @@ protected function findDnsFailureMessage(array $urls): ?string
|
|||
return null;
|
||||
}
|
||||
|
||||
$target = $this->dnsTargetLabel() ?? $server->ip;
|
||||
$results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp);
|
||||
|
||||
foreach ($urls as $url) {
|
||||
try {
|
||||
if (! validateDNSEntry($url, $server)) {
|
||||
return dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return 'Could not validate DNS for this domain.';
|
||||
foreach ($results as $result) {
|
||||
if ($result['status'] === 'failed') {
|
||||
return $result['message'];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,8 +99,8 @@ private function availableTargets(): Collection
|
|||
$label = str($resource->name)->headline();
|
||||
$targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [
|
||||
'key' => 'volume:'.$volume->id,
|
||||
'type' => 'Volume · '.$label,
|
||||
'name' => $volume->name,
|
||||
'type' => $label,
|
||||
'name' => str($volume->name)->after($this->service->uuid.'_')->value(),
|
||||
]));
|
||||
$targets->push(...$resource->fileStorages()
|
||||
->where('is_directory', true)
|
||||
|
|
@ -109,8 +109,8 @@ private function availableTargets(): Collection
|
|||
->get()
|
||||
->map(fn (LocalFileVolume $directory): array => [
|
||||
'key' => 'directory:'.$directory->id,
|
||||
'type' => 'Directory · '.$label,
|
||||
'name' => $directory->fs_path,
|
||||
'type' => $label,
|
||||
'name' => $directory->fs_path.' (directory)',
|
||||
]));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
$configuredCount = collect($domainRows)->where('is_suggested', false)->count();
|
||||
$suggestedCount = collect($domainRows)->where('is_suggested', true)->count();
|
||||
$hasRows = count($domainRows) > 0;
|
||||
$hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking');
|
||||
$composeDomainGroups = collect($domainRows)
|
||||
->groupBy(fn ($row) => $row['service'] ?? '__unknown')
|
||||
->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false)));
|
||||
|
|
@ -36,6 +37,9 @@
|
|||
}"
|
||||
@open-edit-domain.window="openEditDomain()"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
@if ($hasDnsChecksInProgress)
|
||||
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
|
||||
@endif
|
||||
<x-application.settings-section id="domains-section" title="Domains">
|
||||
@can('update', $application)
|
||||
<x-slot:actions>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
'ok' => 'DNS OK',
|
||||
'failed' => 'DNS mismatch',
|
||||
'skipped' => 'DNS skipped',
|
||||
'checking' => 'Checking DNS...',
|
||||
'pending' => 'DNS pending',
|
||||
default => 'DNS unknown',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
$configuredCount = collect($domainRows)->where('is_suggested', false)->count();
|
||||
$suggestedCount = collect($domainRows)->where('is_suggested', true)->count();
|
||||
$hasRows = count($domainRows) > 0;
|
||||
$hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking');
|
||||
$serviceAppCount = count($serviceApps);
|
||||
$domainGroups = collect($domainRows)
|
||||
->groupBy('service_application_id')
|
||||
|
|
@ -37,6 +38,9 @@
|
|||
}"
|
||||
@open-edit-domain.window="openEditDomain()"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
@if ($hasDnsChecksInProgress)
|
||||
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
|
||||
@endif
|
||||
<x-application.settings-section id="service-domains-section" title="Domains">
|
||||
@can('update', $service)
|
||||
<x-slot:actions>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
'ok' => 'DNS OK',
|
||||
'failed' => 'DNS mismatch',
|
||||
'skipped' => 'DNS skipped',
|
||||
'checking' => 'Checking DNS...',
|
||||
'pending' => 'DNS pending',
|
||||
default => 'DNS unknown',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Project\Application\Domains;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
|
|
@ -248,7 +250,7 @@
|
|||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('addDomainDnsFailed', false)
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('success', 'Domain added. DNS check started.')
|
||||
->assertDispatched('close-modal');
|
||||
|
||||
$this->application->refresh();
|
||||
|
|
@ -290,7 +292,9 @@
|
|||
->toContain('https://api.example.com');
|
||||
});
|
||||
|
||||
it('blocks adding a domain with bad dns until the user continues', function () {
|
||||
it('saves a domain before checking dns in a separate request', function () {
|
||||
Queue::fake();
|
||||
|
||||
$settings = InstanceSettings::get();
|
||||
$settings->is_dns_validation_enabled = true;
|
||||
$settings->save();
|
||||
|
|
@ -298,15 +302,8 @@
|
|||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid')
|
||||
->call('addDomain')
|
||||
->assertSet('addDomainDnsFailed', true)
|
||||
->assertSee('DNS is not pointing to the right IP')
|
||||
->assertSee('Are you sure you want to add it anyway');
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->fqdn)->toBeNull();
|
||||
|
||||
$component->call('confirmAddDomainDespiteDns')
|
||||
->assertSet('addDomainDnsFailed', false)
|
||||
->assertSet('domainRows.0.dns_status', 'checking')
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('close-modal');
|
||||
|
||||
|
|
@ -315,17 +312,24 @@
|
|||
'https://this-domain-should-not-resolve-for-coolify-tests.invalid',
|
||||
'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid',
|
||||
]);
|
||||
|
||||
expect($this->application->domain_dns_statuses['https://this-domain-should-not-resolve-for-coolify-tests.invalid']['status'] ?? null)
|
||||
->toBe('checking');
|
||||
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, 2);
|
||||
|
||||
$jobs = Queue::pushed(CheckDomainDnsJob::class);
|
||||
|
||||
expect($jobs->pluck('statusKey')->all())->toEqualCanonicalizing([
|
||||
'https://this-domain-should-not-resolve-for-coolify-tests.invalid',
|
||||
'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid',
|
||||
])->and($jobs->pluck('checkId')->unique())->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('resets the dns gate when the domain input changes', function () {
|
||||
$settings = InstanceSettings::get();
|
||||
$settings->is_dns_validation_enabled = true;
|
||||
$settings->save();
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid')
|
||||
->call('addDomain')
|
||||
->assertSet('addDomainDnsFailed', true)
|
||||
->set('addDomainDnsFailed', true)
|
||||
->set('forceSaveDns', true)
|
||||
->set('newDomain', 'https://another.example.com')
|
||||
->assertSet('addDomainDnsFailed', false)
|
||||
->assertSet('forceSaveDns', false);
|
||||
|
|
@ -727,6 +731,98 @@
|
|||
->and($entry['checked_at'] ?? null)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('polls a queued dns check and notifies about a mismatch', function () {
|
||||
$domain = 'https://app.example.com';
|
||||
$this->application->update([
|
||||
'fqdn' => $domain,
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'checking',
|
||||
'message' => 'Checking DNS...',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSee('Checking DNS...')
|
||||
->assertSee('wire:poll.2000ms="pollDnsChecks"', false);
|
||||
|
||||
$this->application->update([
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'failed',
|
||||
'message' => 'Required DNS record type A pointing to 203.0.113.10',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$component->call('pollDnsChecks')
|
||||
->assertSet('domainRows.0.dns_status', 'failed')
|
||||
->assertDispatched('error', 'DNS is not configured for app.example.com. Review the required DNS record.');
|
||||
});
|
||||
|
||||
it('does not overwrite a completed queued dns result with stale checking state', function () {
|
||||
$domain = 'https://app.example.com';
|
||||
$this->application->update([
|
||||
'fqdn' => $domain,
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'checking',
|
||||
'message' => 'Checking DNS...',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
|
||||
|
||||
$this->application->update([
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'ok',
|
||||
'message' => 'DNS looks correct.',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$method = new ReflectionMethod($component->instance(), 'persistDomainDnsStatuses');
|
||||
$method->invoke($component->instance());
|
||||
|
||||
expect($this->application->fresh()->domain_dns_statuses[$domain]['status'])->toBe('ok');
|
||||
});
|
||||
|
||||
it('does not overwrite a newer queued dns check with stale completed component state', function () {
|
||||
$domain = 'https://app.example.com';
|
||||
$status = [
|
||||
'status' => 'ok',
|
||||
'message' => 'DNS looks correct.',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => null,
|
||||
'check_id' => null,
|
||||
];
|
||||
$this->application->update([
|
||||
'fqdn' => $domain,
|
||||
'domain_dns_statuses' => [$domain => $status],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
|
||||
|
||||
$status['check_id'] = 'newer-check';
|
||||
$this->application->update(['domain_dns_statuses' => [$domain => $status]]);
|
||||
|
||||
$method = new ReflectionMethod($component->instance(), 'persistDomainDnsStatuses');
|
||||
$method->invoke($component->instance());
|
||||
|
||||
expect($this->application->fresh()->domain_dns_statuses[$domain]['check_id'])->toBe('newer-check');
|
||||
});
|
||||
|
||||
it('resolves hostname server addresses to a real ip for dns messages', function () {
|
||||
$this->server->update(['ip' => 'localhost']);
|
||||
$this->application->update([
|
||||
|
|
|
|||
119
tests/Feature/CheckDomainDnsJobTest.php
Normal file
119
tests/Feature/CheckDomainDnsJobTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(fn () => CheckDomainDns::clearFake());
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'is_dns_validation_enabled' => false,
|
||||
]));
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => 1,
|
||||
'destination_type' => 'App\\Models\\StandaloneDocker',
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'domain_dns_statuses' => [
|
||||
'https://app.example.com' => [
|
||||
'status' => 'checking',
|
||||
'message' => 'Checking DNS...',
|
||||
'expected_ip' => null,
|
||||
'checked_at' => null,
|
||||
'check_id' => 'test-check',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists a skipped result when dns validation is disabled', function () {
|
||||
(new CheckDomainDnsJob(
|
||||
$this->application,
|
||||
'https://app.example.com',
|
||||
'https://app.example.com',
|
||||
null,
|
||||
null,
|
||||
'test-check',
|
||||
))->handle();
|
||||
|
||||
$status = $this->application->fresh()->domain_dns_statuses['https://app.example.com'];
|
||||
|
||||
expect($status['status'])->toBe('skipped')
|
||||
->and($status['message'])->toBe('DNS validation is disabled in instance settings.')
|
||||
->and($status['checked_at'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('does not restore a dns status removed before the job finishes', function () {
|
||||
$this->application->update(['domain_dns_statuses' => null]);
|
||||
|
||||
(new CheckDomainDnsJob(
|
||||
$this->application,
|
||||
'https://app.example.com',
|
||||
'https://app.example.com',
|
||||
null,
|
||||
null,
|
||||
'test-check',
|
||||
))->handle();
|
||||
|
||||
expect($this->application->fresh()->domain_dns_statuses)->toBeNull();
|
||||
});
|
||||
|
||||
it('uses the shared dns action', function () {
|
||||
CheckDomainDns::shouldRun()
|
||||
->once()
|
||||
->andReturn([
|
||||
'https://app.example.com' => [
|
||||
'status' => 'ok',
|
||||
'message' => 'DNS looks correct.',
|
||||
'expected_ip' => null,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
|
||||
(new CheckDomainDnsJob(
|
||||
$this->application,
|
||||
'https://app.example.com',
|
||||
'https://app.example.com',
|
||||
null,
|
||||
null,
|
||||
'test-check',
|
||||
))->handle();
|
||||
|
||||
expect($this->application->fresh()->domain_dns_statuses['https://app.example.com']['status'])->toBe('ok');
|
||||
});
|
||||
|
||||
it('does not let an older job overwrite a newer check for the same domain', function () {
|
||||
$oldJob = new CheckDomainDnsJob(
|
||||
$this->application,
|
||||
'https://app.example.com',
|
||||
'https://app.example.com',
|
||||
null,
|
||||
null,
|
||||
'test-check',
|
||||
);
|
||||
|
||||
$statuses = $this->application->domain_dns_statuses;
|
||||
$statuses['https://app.example.com']['check_id'] = 'newer-check';
|
||||
$this->application->update(['domain_dns_statuses' => $statuses]);
|
||||
|
||||
$oldJob->handle();
|
||||
|
||||
$status = $this->application->fresh()->domain_dns_statuses['https://app.example.com'];
|
||||
|
||||
expect($status['status'])->toBe('checking')
|
||||
->and($status['check_id'])->toBe('newer-check');
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -9,6 +10,27 @@
|
|||
|
||||
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],
|
||||
|
|
@ -51,7 +73,40 @@ public function hasError(): bool
|
|||
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Project\Service\Domains;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
|
|
@ -283,39 +285,38 @@
|
|||
it('adds only the entered domain when redirects allow both directions', function () {
|
||||
$this->webApp->update(['redirect' => 'both']);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->set('newServiceApplicationId', $this->webApp->id)
|
||||
->set('newDomain', 'https://web.example.com')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('success');
|
||||
|
||||
$component->call('pollDnsChecks')
|
||||
->assertSee('DNS skipped');
|
||||
|
||||
expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com');
|
||||
});
|
||||
|
||||
it('adds a domain to a selected service application', function () {
|
||||
Queue::fake();
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->set('newServiceApplicationId', $this->webApp->id)
|
||||
->set('newDomain', 'https://web.example.com')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('success', 'Domain added. DNS check started.')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->firstWhere('url', 'https://web.example.com')['dns_status'] === 'checking')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://web.example.com'))
|
||||
->assertSee('https://web.example.com');
|
||||
|
||||
$this->webApp->refresh();
|
||||
expect($this->webApp->fqdn)->toBe('https://web.example.com');
|
||||
|
||||
$dnsStatuses = $this->webApp->domain_dns_statuses;
|
||||
expect($this->webApp->domain_dns_statuses['https://web.example.com']['status'] ?? null)->toBe('checking');
|
||||
|
||||
expect($dnsStatuses)
|
||||
->toHaveKey('https://web.example.com')
|
||||
->not->toHaveKey('https://www.web.example.com')
|
||||
->and($dnsStatuses['https://web.example.com']['status'])
|
||||
->toBe('skipped')
|
||||
->and($dnsStatuses['https://web.example.com']['checked_at'])
|
||||
->not->toBeNull();
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, 1);
|
||||
});
|
||||
|
||||
it('adds a domain when the compose service has an empty environment section', function () {
|
||||
|
|
@ -457,7 +458,7 @@
|
|||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('removeDomain', 0)
|
||||
->set('newServiceApplicationId', $this->apiApp->id)
|
||||
->set('newDomain', 'https://api.example.com')
|
||||
|
|
@ -465,6 +466,8 @@
|
|||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
$component->call('pollDnsChecks');
|
||||
|
||||
$this->apiApp->refresh();
|
||||
|
||||
expect(explode(',', (string) $this->apiApp->fqdn))
|
||||
|
|
@ -564,6 +567,39 @@
|
|||
->assertDontSee('DNS points to 203.0.113.10');
|
||||
});
|
||||
|
||||
it('polls a queued service dns check and notifies about success', function () {
|
||||
$domain = 'https://api.example.com';
|
||||
$this->apiApp->update([
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'checking',
|
||||
'message' => 'Checking DNS...',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSee('Checking DNS...')
|
||||
->assertSee('wire:poll.2000ms="pollDnsChecks"', false);
|
||||
|
||||
$this->apiApp->update([
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'ok',
|
||||
'message' => 'DNS looks correct.',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$component->call('pollDnsChecks')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->firstWhere('url', $domain)['dns_status'] === 'ok')
|
||||
->assertDispatched('success', 'DNS is configured correctly for api.example.com.');
|
||||
});
|
||||
|
||||
it('forbids read-only users from checking service domain dns', function (string $action, array $parameters) {
|
||||
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Jobs\VolumeBackupRecoveryJob;
|
||||
use App\Livewire\Project\Application\Backup\Create as CreateScheduledVolumeBackup;
|
||||
use App\Livewire\Project\Service\FileStorage;
|
||||
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
|
||||
use App\Livewire\Project\Shared\Storages\Show;
|
||||
use App\Livewire\Project\Shared\Storages\VolumeBackups;
|
||||
use App\Models\Application;
|
||||
|
|
@ -20,6 +21,7 @@
|
|||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
|
|
@ -187,6 +189,45 @@
|
|||
->and($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('shows readable service storage backup target labels', function () {
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
[$application] = createVolumeBackupApplication($team);
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $application->environment_id,
|
||||
'destination_id' => $application->destination_id,
|
||||
'destination_type' => $application->destination_type,
|
||||
]);
|
||||
$resource = ServiceApplication::create([
|
||||
'uuid' => new_public_id(),
|
||||
'name' => 'directus',
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $service->uuid.'_directus-templates',
|
||||
'mount_path' => '/directus/templates',
|
||||
'resource_id' => $resource->id,
|
||||
'resource_type' => $resource->getMorphClass(),
|
||||
]);
|
||||
LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
|
||||
'uuid' => new_public_id(),
|
||||
'fs_path' => './uploads',
|
||||
'mount_path' => '/directus/uploads',
|
||||
'is_directory' => true,
|
||||
'is_based_on_git' => false,
|
||||
'is_preview_suffix_enabled' => true,
|
||||
'resource_id' => $resource->id,
|
||||
'resource_type' => $resource->getMorphClass(),
|
||||
])));
|
||||
|
||||
Livewire::test(CreateServiceVolumeBackup::class, ['service' => $service])
|
||||
->assertSet('targets.0.name', 'directus-templates')
|
||||
->assertSet('targets.0.type', 'Directus')
|
||||
->assertSet('targets.1.name', './uploads (directory)')
|
||||
->assertSet('targets.1.type', 'Directus')
|
||||
->assertSee('Directus: directus-templates');
|
||||
});
|
||||
|
||||
it('handles scheduled backup persistence failures', function () {
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
|
|
|
|||
Loading…
Reference in a new issue