*/ public array $serviceRedirects = []; /** Compose service name when a pending domain conflict belongs to setServiceRedirect. */ public ?string $pendingRedirectService = null; public string $newDomain = ''; public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; public bool $newDomainPartsChanged = false; public ?string $newDomainService = null; public ?int $editingIndex = null; public string $editingDomain = ''; public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; public bool $editingDomainPartsChanged = false; public ?string $editingService = null; /** @var array */ public array $domainRows = []; /** When set, the next addSuggestedDomain call for this index skips the DNS block. */ public ?int $forceAddSuggestedIndex = null; /** @var array */ public array $composeServices = []; public array $domainConflicts = []; public bool $showDomainConflictModal = false; public bool $forceSaveDomains = false; public bool $forceSaveDns = false; public bool $addDomainDnsFailed = false; public string $addDomainDnsMessage = ''; public bool $showEditDomainModal = false; public bool $forceSaveEditDns = false; public bool $editDomainDnsFailed = false; public string $editDomainDnsMessage = ''; /** Pending save path after conflict confirmation: add | update | suggested | redirect */ public ?string $pendingAction = null; public bool $isCompose = false; public bool $labelsAreWritable = false; public bool $isCheckingDns = false; public bool $dnsValidationEnabled = true; /** Resolved or literal IP users should point DNS at. */ public ?string $serverIp = null; /** Raw server IP/hostname as configured (may be a hostname). */ public ?string $serverIpConfigured = null; protected $listeners = [ 'configurationChanged' => 'refreshDomains', 'confirmDomainUsage', ]; protected function rules(): array { return [ 'newDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(), 'redirect' => 'string|required|in:both,www,non-www', 'serviceRedirects' => 'array', 'serviceRedirects.*' => 'string|in:both,www,non-www', ]; } protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), [ 'redirect.required' => 'The Redirect setting is required.', 'redirect.string' => 'The Redirect setting must be a string.', 'redirect.in' => 'The Redirect setting must be both, www, or non-www.', 'serviceRedirects.*.in' => 'The Redirect setting must be both, www, or non-www.', ] ); } public function mount(): void { $this->authorize('view', $this->application); $this->loadDomainState(); } public function refreshDomains(): void { $this->loadDomainState(); } public function toggleNoindexDomain(string $domain, string|bool $indexing): void { $this->authorize('update', $this->application); $noindex = $indexing === true || $indexing === 'noindex'; $domains = $this->application->noindexDomains(); $domains = $noindex ? $domains->push($domain) : $domains->reject(fn (string $item) => $item === $domain); $this->application->setNoindexDomains($domains); $this->application->save(); $this->application->refresh(); $this->resetDefaultLabels(); $this->dispatch('configurationChanged')->to(ConfigurationChecker::class); $this->dispatch('success', 'Search engine indexing updated.'); } public function updateRedirect(string $redirect): void { $this->redirect = $redirect; $this->setRedirect(); } public function loadDomainState(): void { $this->application->refresh(); $this->application->loadMissing(['destination.server', 'settings', 'additional_servers']); $this->isCompose = $this->application->build_pack === 'dockercompose'; $this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false; $this->redirect = $this->application->redirect ?? 'both'; $settings = instanceSettings(); $this->dnsValidationEnabled = (bool) data_get($settings, 'is_dns_validation_enabled', true); $server = $this->application->destination?->server; if ($server) { if ($server->id === 0) { $configured = data_get($settings, 'public_ipv4') ?: data_get($settings, 'public_ipv6') ?: $server->ip; } else { $configured = $server->ip; } $resolved = resolveServerIpAddress(is_string($configured) ? $configured : null); $this->serverIpConfigured = $resolved['configured']; // Prefer real IP so users know what DNS record to set even when the // server address is stored as a hostname (e.g. coolify-testing-host). $this->serverIp = $resolved['ip'] ?? $resolved['configured']; } else { $this->serverIp = null; $this->serverIpConfigured = null; } $this->composeServices = []; $this->serviceRedirects = []; if ($this->isCompose) { try { $parsed = $this->application->parse() ?? []; } catch (\Throwable) { $parsed = []; } $services = data_get($parsed, 'services', []); foreach ($services as $serviceName => $service) { if (! isDatabaseImage(data_get($service, 'image'))) { $this->composeServices[] = $serviceName; } } if ($this->newDomainService === null && count($this->composeServices) > 0) { $this->newDomainService = $this->composeServices[0]; } $domains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; if (! is_array($domains)) { $domains = []; } $serviceNames = $this->composeServices; foreach (array_keys($domains) as $serviceName) { if (! in_array($serviceName, $serviceNames, true)) { $serviceNames[] = $serviceName; } } foreach ($serviceNames as $serviceName) { // data_get treats dots as path separators; read the service entry by array key. $serviceEntry = $domains[$serviceName] ?? null; $storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null; $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect( is_string($storedRedirect) ? $storedRedirect : null ); } } // Do not auto-promote www/non-www pairs here: load/refresh also runs after // removeDomain, and re-adding counterparts would undo intentional deletes. // Pairs are still ensured on setRedirect, addDomain, and generateDomain. $this->domainRows = $this->buildDomainRows(); } /** * Livewire wire:model treats dots as nesting (serviceRedirects.api.test). * Encode them so compose service names like "api.test" stay flat string values. */ public function serviceRedirectWireKey(string $serviceName): string { return str_replace('.', '__dot__', $serviceName); } protected function normalizeRedirect(?string $redirect): string { return in_array($redirect, ['www', 'non-www', 'both'], true) ? $redirect : 'both'; } protected function serviceRedirectFor(?string $serviceName): string { if ($serviceName === null) { return $this->normalizeRedirect($this->redirect); } return $this->normalizeRedirect( $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] ?? null ); } /** * @return array */ protected function buildDomainRows(): array { $rows = []; $stored = $this->storedDomainDnsStatuses(); if ($this->isCompose) { $domains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; if (! is_array($domains)) { $domains = []; } $serviceNames = $this->composeServices; foreach (array_keys($domains) as $serviceName) { if (! in_array($serviceName, $serviceNames, true)) { $serviceNames[] = $serviceName; } } foreach ($serviceNames as $serviceName) { $configured = []; // Array key access: data_get() would treat dots in service names as path separators. $domainString = is_array($domains[$serviceName] ?? null) ? ($domains[$serviceName]['domain'] ?? null) : null; foreach ($this->splitDomains(is_string($domainString) ? $domainString : null) as $url) { $row = $this->domainRowFromStored($url, $serviceName, $stored); $rows[] = $row; $configured[] = $row; } } return $this->sortDomainRowsByDnsStatus($rows); } foreach ($this->splitDomains($this->application->fqdn) as $url) { $rows[] = $this->domainRowFromStored($url, null, $stored); } return $this->sortDomainRowsByDnsStatus($rows); } /** * @param array> $rows * @return array> */ protected function sortDomainRowsByDnsStatus(array $rows): array { return collect($rows) ->sortBy(fn (array $row): int => ($row['dns_status'] ?? null) === 'failed' ? 0 : 1) ->values() ->all(); } /** * Missing www/non-www counterparts as full domain rows (DNS-checkable, not yet saved). * * Messaging depends on Direction: * - both: both hosts are first-class and need DNS to the server * - www / non-www: the missing pair is for Coolify's redirect (still must resolve to * the server so the proxy can redirect; not framed as a separate primary site) * * @param array> $configuredRows * @param array $stored * @return array */ protected function buildSuggestedWwwRows(array $configuredRows, array $stored, ?string $service = null): array { $knownHosts = []; foreach ($configuredRows as $row) { $host = $this->domainHost((string) ($row['url'] ?? '')); if ($host !== null) { $knownHosts[strtolower($host)] = true; } } $suggested = []; foreach ($configuredRows as $row) { $url = (string) ($row['url'] ?? ''); $rowService = $service ?? ($row['service'] ?? null); $counterpart = $this->wwwCounterpartUrl($url); if ($counterpart === null) { continue; } $counterpartHost = $this->domainHost($counterpart); if ($counterpartHost === null) { continue; } $hostKey = strtolower($counterpartHost); if (isset($knownHosts[$hostKey])) { continue; } $knownHosts[$hostKey] = true; $base = $this->domainRowFromStored($counterpart, $rowService, $stored); $isWww = str_starts_with($hostKey, 'www.'); $meta = $this->suggestedDomainMeta($isWww, $this->serviceRedirectFor(is_string($rowService) ? $rowService : null)); $base['is_suggested'] = true; $base['suggested_for'] = $url; $base['suggestion_label'] = null; $base['suggestion_role'] = $meta['role']; $base['needs_force_add'] = false; $base['dns_message'] = $meta['pending_message']; $suggested[] = $base; } return $suggested; } /** * Labels/copy for a suggested www or non-www host based on Direction. * * @return array{label: string, role: string, pending_message: string, dns_suffix: string} */ protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOverride = null): array { $pendingMessage = 'Not configured yet.'; $redirect = $redirectOverride ?? ($this->redirect ?: 'both'); return match ($redirect) { 'www' => $suggestedIsWww ? [ 'label' => 'Not added · canonical www', 'role' => 'canonical', 'pending_message' => $pendingMessage, 'dns_suffix' => '', ] : [ 'label' => 'Not added · redirect source', 'role' => 'redirect_source', 'pending_message' => $pendingMessage, 'dns_suffix' => '', ], 'non-www' => $suggestedIsWww ? [ 'label' => 'Not added · redirect source', 'role' => 'redirect_source', 'pending_message' => $pendingMessage, 'dns_suffix' => '', ] : [ 'label' => 'Not added · canonical non-www', 'role' => 'canonical', 'pending_message' => $pendingMessage, 'dns_suffix' => '', ], default => [ 'label' => $suggestedIsWww ? 'Not added · www' : 'Not added · non-www', 'role' => 'pair', 'pending_message' => $pendingMessage, 'dns_suffix' => '', ], }; } /** * @param array $stored * @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool} */ protected function domainRowFromStored(string $url, ?string $service, array $stored): array { $key = $this->domainDnsStatusKey($url, $service); $entry = $stored[$key] ?? null; if (is_array($entry) && filled(data_get($entry, 'status'))) { return [ 'url' => $url, 'service' => $service, 'dns_status' => (string) data_get($entry, 'status', 'pending'), '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'), 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, 'needs_force_add' => false, ]; } return [ 'url' => $url, 'service' => $service, 'dns_status' => 'pending', 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, 'checked_at' => null, 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, 'needs_force_add' => false, ]; } /** * @return array */ protected function storedDomainDnsStatuses(): array { $stored = $this->application->domain_dns_statuses; return is_array($stored) ? $stored : []; } protected function domainDnsStatusKey(string $url, ?string $service = null): string { return $service ? "{$service}|{$url}" : $url; } /** * @return array */ protected function splitDomains(?string $value): array { if (blank($value)) { return []; } return collect(explode(',', $value)) ->map(fn ($domain) => trim((string) $domain)) ->filter() ->values() ->all(); } protected function authorizeUpdateForDomainConnect(): void { $this->authorize('update', $this->application); } public function checkAllDns(): void { $this->authorize('update', $this->application); $this->isCheckingDns = true; try { $server = $this->application->destination?->server; $skipDns = ! $this->dnsValidationEnabled || ! $server || $this->application->additional_servers->count() > 0; foreach ($this->domainRows as $index => $row) { if ($skipDns) { $reason = ! $this->dnsValidationEnabled ? 'DNS validation is disabled in instance settings.' : ($this->application->additional_servers->count() > 0 ? 'DNS check skipped for multi-server applications.' : 'No server available for DNS validation.'); $this->domainRows[$index]['dns_status'] = 'skipped'; $this->domainRows[$index]['dns_message'] = $reason; $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); continue; } $this->applyDnsStatus($index, $row['url'], $server); } $this->persistDomainDnsStatuses(); } finally { $this->isCheckingDns = false; } } public function checkDomainDns(int $index): void { $this->authorize('update', $this->application); if (! isset($this->domainRows[$index])) { return; } $server = $this->application->destination?->server; if (! $server || ! $this->dnsValidationEnabled || $this->application->additional_servers->count() > 0) { $this->domainRows[$index]['dns_status'] = 'skipped'; $this->domainRows[$index]['dns_message'] = 'DNS check skipped.'; $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); $this->persistDomainDnsStatuses(); return; } $this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); $this->persistDomainDnsStatuses(); } protected function applyDnsStatus(int $index, string $url, Server $server): void { $target = $this->dnsTargetLabel(); 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.'; } // 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'] = $this->serverIp; $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); } /** * Persist current domain row DNS results and drop statuses for removed domains. */ protected function persistDomainDnsStatuses(): void { $statuses = []; foreach ($this->domainRows as $row) { $url = $row['url'] ?? null; if (! is_string($url) || $url === '') { continue; } $status = $row['dns_status'] ?? 'pending'; if ($status === 'pending') { // Keep previously stored value for unchecked domains if present. $key = $this->domainDnsStatusKey($url, $row['service'] ?? null); $existing = data_get($this->storedDomainDnsStatuses(), $key); if (is_array($existing) && filled(data_get($existing, 'status'))) { $statuses[$key] = $existing; } continue; } $key = $this->domainDnsStatusKey($url, $row['service'] ?? null); $statuses[$key] = [ 'status' => $status, 'message' => (string) ($row['dns_message'] ?? ''), 'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), ]; } $this->application->domain_dns_statuses = $statuses === [] ? null : $statuses; $this->application->save(); } protected function pruneDomainDnsStatusesToCurrentDomains(): void { $this->application->refresh(); // Rebuild rows from current domain list, keep only matching stored keys. $this->domainRows = $this->buildDomainRows(); $this->persistDomainDnsStatuses(); } /** * Human-readable DNS target: real IP, with configured hostname when it differs. */ public function dnsTargetLabel(): ?string { if (blank($this->serverIp)) { return $this->serverIpConfigured; } if ( filled($this->serverIpConfigured) && $this->serverIpConfigured !== $this->serverIp && filter_var($this->serverIpConfigured, FILTER_VALIDATE_IP) === false ) { return "{$this->serverIp} ({$this->serverIpConfigured})"; } return $this->serverIp; } public function updatedNewDomain(): void { $this->resetAddDomainDnsGate(); } public function updatedNewDomainParts(): void { $this->newDomainPartsChanged = true; $this->resetAddDomainDnsGate(); } public function updatedNewDomainService(): void { $this->resetAddDomainDnsGate(); } public function resetAddDomainDnsGate(): void { $this->addDomainDnsFailed = false; $this->addDomainDnsMessage = ''; $this->forceSaveDns = false; } public function resetAddDomainForm(): void { $this->newDomain = ''; $this->newDomainParts = DomainUrlParts::empty(); $this->newDomainPartsChanged = false; $this->resetAddDomainDnsGate(); $this->resetErrorBag('newDomain'); } public function confirmAddDomainDespiteDns(): void { $this->forceSaveDns = true; $this->addDomain(); } public function confirmDomainUsage(): void { $this->forceSaveDomains = true; $this->showDomainConflictModal = false; if ($this->pendingAction === 'update') { $this->updateDomain(); return; } if ($this->pendingAction === 'suggested') { $this->addSuggestedDomain((int) ($this->editingIndex ?? -1)); return; } if ($this->pendingAction === 'redirect') { if ($this->isCompose && filled($this->pendingRedirectService)) { $this->setServiceRedirect($this->pendingRedirectService); return; } $this->setRedirect(); return; } $this->addDomain(); } /** * Clear pending conflict state when the modal is dismissed without confirmation. * confirmDomainUsage sets forceSaveDomains before closing the modal. */ public function updatedShowDomainConflictModal(bool $value): void { if ($value || $this->forceSaveDomains) { return; } $this->pendingAction = null; } public function addDomain(): void { try { $this->authorize('update', $this->application); if ($this->labelsAreWritable) { $this->dispatch('error', 'Domains cannot be edited while container labels are writable. Set domains in the Labels section on General.'); return; } if ($this->newDomainPartsChanged) { $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); } $this->validateOnly('newDomain'); $normalized = ValidationPatterns::normalizeApplicationDomains($this->newDomain); if (blank($normalized)) { $this->addError('newDomain', 'Please enter a valid domain URL.'); return; } $newUrls = $this->splitDomains($normalized); $pairedUrls = collect($newUrls) ->map(fn (string $url) => $this->wwwCounterpartUrl($url)) ->filter() ->values() ->all(); $current = $this->currentDomainList($this->newDomainService); foreach ($newUrls as $url) { if ($current->contains($url)) { $this->addError('newDomain', "Domain {$url} is already configured."); return; } } 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)) { return; } $this->forceSaveDomains = false; $this->pendingAction = null; $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); } catch (\Throwable $e) { handleError($e, $this); } } /** * Run a first-time DNS check for newly added/updated domain URLs and persist results. * * @param array $urls */ protected function checkUrlsDns(array $urls, ?string $service = null): void { if ($urls === []) { return; } $urlSet = array_fill_keys($urls, true); $server = $this->application->destination?->server; $skipDns = ! $this->dnsValidationEnabled || ! $server || $this->application->additional_servers->count() > 0; foreach ($this->domainRows as $index => $row) { $url = $row['url'] ?? null; if (! is_string($url) || ! isset($urlSet[$url])) { continue; } if ($service !== null && ($row['service'] ?? null) !== $service) { continue; } if ($skipDns) { $reason = ! $this->dnsValidationEnabled ? 'DNS validation is disabled in instance settings.' : ($this->application->additional_servers->count() > 0 ? 'DNS check skipped for multi-server applications.' : 'No server available for DNS validation.'); $this->domainRows[$index]['dns_status'] = 'skipped'; $this->domainRows[$index]['dns_message'] = $reason; $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); $this->domainRows[$index]['expected_ip'] = $this->serverIp; continue; } $this->applyDnsStatus($index, $url, $server); } $this->persistDomainDnsStatuses(); } protected function shouldValidateDnsForAdd(): bool { if (! $this->dnsValidationEnabled) { return false; } $server = $this->application->destination?->server; if (! $server) { return false; } if ($this->application->additional_servers->count() > 0) { return false; } return true; } /** * @param array $urls */ protected function findDnsFailureMessage(array $urls): ?string { $server = $this->application->destination?->server; if (! $server) { return null; } $target = $this->dnsTargetLabel() ?? $server->ip; foreach ($urls as $url) { try { if (! validateDNSEntry($url, $server)) { return dnsMismatchGuidanceMessage($target, $this->serverIp); } } catch (\Throwable) { return 'Could not validate DNS for this domain.'; } } return null; } public function updatedEditingDomain(): void { $this->resetEditDomainDnsGate(); } public function updatedEditingDomainParts(): void { $this->editingDomainPartsChanged = true; $this->resetEditDomainDnsGate(); } public function resetEditDomainDnsGate(): void { $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; $this->forceSaveEditDns = false; } public function startEdit(int $index): void { if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) { return; } $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); $this->editingDomainPartsChanged = false; $this->editingService = $this->domainRows[$index]['service']; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); $this->showEditDomainModal = true; $this->dispatch('open-edit-domain'); } public function addSuggestedDomain(int $index): void { try { $this->authorize('update', $this->application); if ($this->labelsAreWritable) { $this->dispatch('error', 'Domains cannot be edited while container labels are writable.'); return; } if (! isset($this->domainRows[$index]) || ! ($this->domainRows[$index]['is_suggested'] ?? false)) { return; } $url = (string) $this->domainRows[$index]['url']; $normalized = ValidationPatterns::normalizeApplicationDomains($url); if (blank($normalized)) { $this->dispatch('error', 'Invalid suggested domain.'); return; } $serviceName = $this->domainRows[$index]['service'] ?? null; $newUrls = $this->splitDomains($normalized); $current = $this->currentDomainList($serviceName); foreach ($newUrls as $newUrl) { if ($current->contains($newUrl)) { $this->refreshDomains(); return; } } $force = $this->forceAddSuggestedIndex === $index || (bool) ($this->domainRows[$index]['needs_force_add'] ?? false); if (! $force && $this->shouldValidateDnsForAdd()) { $dnsFailure = $this->findDnsFailureMessage($newUrls); if ($dnsFailure !== null) { $this->domainRows[$index]['dns_status'] = 'failed'; $this->domainRows[$index]['dns_message'] = $dnsFailure; $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); $this->domainRows[$index]['needs_force_add'] = true; $this->forceAddSuggestedIndex = $index; $this->editingIndex = $index; $this->persistDomainDnsStatuses(); return; } } $merged = $current->merge($newUrls)->unique()->values(); $this->pendingAction = 'suggested'; $this->editingIndex = $index; if (! $this->saveDomainList($merged, $serviceName)) { return; } $this->forceAddSuggestedIndex = null; $this->editingIndex = null; $this->pendingAction = null; $this->forceSaveDomains = false; $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); $this->checkUrlsDns($newUrls, $serviceName); } catch (\Throwable $e) { handleError($e, $this); } } public function cancelEdit(): void { $this->showEditDomainModal = false; $this->editingIndex = null; $this->editingDomain = ''; $this->editingDomainParts = DomainUrlParts::empty(); $this->editingDomainPartsChanged = false; $this->editingService = null; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); if ($this->pendingAction === 'update') { $this->pendingAction = null; $this->forceSaveDomains = false; $this->showDomainConflictModal = false; } } public function confirmUpdateDomainDespiteDns(): void { $this->forceSaveEditDns = true; $this->updateDomain(); } public function updateDomain(): void { try { $this->authorize('update', $this->application); if ($this->labelsAreWritable) { $this->dispatch('error', 'Domains cannot be edited while container labels are writable.'); return; } if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) { return; } if ($this->editingDomainPartsChanged) { $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); } $this->validateOnly('editingDomain'); $normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain); if (blank($normalized) || count($this->splitDomains($normalized)) !== 1) { $this->addError('editingDomain', 'Please enter a single valid domain URL.'); return; } $newUrl = $this->splitDomains($normalized)[0]; $oldUrl = $this->domainRows[$this->editingIndex]['url']; $service = $this->editingService; $wasNoindexed = $this->application->isDomainNoindexed($oldUrl); $current = $this->currentDomainList($service); if ($newUrl !== $oldUrl && $current->contains($newUrl)) { $this->addError('editingDomain', "Domain {$newUrl} is already configured."); return; } if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) { $dnsFailure = $this->findDnsFailureMessage([$newUrl]); if ($dnsFailure !== null) { $this->editDomainDnsFailed = true; $this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure); $this->showEditDomainModal = true; return; } } $updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values(); $this->pendingAction = 'update'; if (! $this->saveDomainList($updated, $service)) { return; } $noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl); if ($wasNoindexed) { $noindexDomains->push($newUrl); } $this->application->setNoindexDomains($noindexDomains); $this->application->save(); $this->resetDefaultLabels(); $this->forceSaveDomains = false; $this->pendingAction = null; $this->cancelEdit(); $this->dispatch('edit-domain-saved'); $this->dispatch('success', 'Domain updated.'); $this->refreshDomains(); $this->checkUrlsDns([$newUrl], $service); } catch (\Throwable $e) { handleError($e, $this); } } public function removeDomain(int $index): void { try { $this->authorize('update', $this->application); if ($this->labelsAreWritable) { $this->dispatch('error', 'Domains cannot be edited while container labels are writable.'); return; } if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) { return; } $url = $this->domainRows[$index]['url']; $service = $this->domainRows[$index]['service']; $updated = $this->currentDomainList($service)->reject(fn (string $item) => $item === $url)->values(); if (! $this->saveDomainList($updated, $service, checkConflicts: false)) { return; } if ($this->editingIndex === $index) { $this->cancelEdit(); } $this->dispatch('success', 'Domain removed.'); $this->refreshDomains(); $this->pruneDomainDnsStatusesToCurrentDomains(); } catch (\Throwable $e) { handleError($e, $this); } } public function generateDomain(?string $serviceName = null): void { try { $this->authorize('update', $this->application); if ($this->labelsAreWritable) { $this->dispatch('error', 'Domains cannot be edited while container labels are writable.'); return; } $server = data_get($this->application, 'destination.server'); if (! $server) { $this->dispatch('error', 'No server found for this application.'); return; } if ($this->isCompose) { $serviceName = $serviceName ?: $this->newDomainService ?: ($this->composeServices[0] ?? null); if (! $serviceName) { $this->dispatch('error', 'No compose service available for domain generation.'); return; } $domain = generateUrl(server: $server, random: new_public_id()); $current = $this->currentDomainList($serviceName); $merged = $current->push($domain)->unique()->values(); if (! $this->saveDomainList($merged, $serviceName, checkConflicts: false)) { return; } $pairedUrls = $this->syncRedirectDomainPairs($serviceName); $this->resetAddDomainForm(); $this->dispatch('close-modal'); $this->dispatch('success', 'Domain generated.'); $this->refreshDomains(); $this->checkUrlsDns(array_values(array_unique(array_merge([$domain], $pairedUrls))), $serviceName); return; } $fqdn = generateUrl(server: $server, random: $this->application->uuid); $merged = $this->currentDomainList()->push($fqdn)->unique()->values(); if (! $this->saveDomainList($merged, null, checkConflicts: false)) { return; } $pairedUrls = $this->syncRedirectDomainPairs(null); $this->resetAddDomainForm(); $this->dispatch('close-modal'); $this->dispatch('success', 'Domain generated.'); $this->refreshDomains(); $this->checkUrlsDns(array_values(array_unique(array_merge([$fqdn], $pairedUrls)))); } catch (\Throwable $e) { handleError($e, $this); } } public function setRedirect(): void { try { $this->authorize('update', $this->application); if ($this->isCompose) { $this->dispatch('error', 'Set the redirect direction per compose service.'); return; } $this->validateOnly('redirect'); $this->application->redirect = $this->redirect; // www / non-www redirects need both hosts configured as real domains so the // proxy can serve the canonical host and redirect the other. Auto-add missing // counterparts instead of leaving them as optional suggestions. $addedDomains = []; if (in_array($this->redirect, ['www', 'non-www'], true)) { $domainsBeforePairing = $this->currentDomainList(); if (! $this->ensureWwwNonWwwPairsConfigured(null)) { return; } $this->application->refresh(); $this->application->redirect = $this->redirect; $addedDomains = $this->currentDomainList()->diff($domainsBeforePairing)->values()->all(); } $domains = collect($this->application->fqdns); if (! $this->assertRedirectDomainsPresent($this->redirect, $domains)) { return; } $this->application->save(); $this->pendingAction = null; $this->pendingRedirectService = null; $this->forceSaveDomains = false; $this->resetDefaultLabels(); $this->dispatch('success', 'Redirect updated.'); $this->refreshDomains(); $this->checkUrlsDns($addedDomains); $this->pruneDomainDnsStatusesToCurrentDomains(); } catch (\Throwable $e) { handleError($e, $this); } } public function updateServiceRedirect(string $serviceName, string $redirect): void { $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $redirect; $this->setServiceRedirect($serviceName); } /** * @param mixed ...$modalArgs Extra args from modal-confirmation (password, etc.) */ public function setServiceRedirect(string $serviceName, mixed ...$modalArgs): void { try { $this->authorize('update', $this->application); if (! $this->isCompose) { $this->dispatch('error', 'Per-service redirect is only available for Docker Compose applications.'); return; } // modal-confirmation passes string args with surrounding quotes intact. $serviceName = trim($serviceName, " \t\n\r\0\x0B'\""); if (blank($serviceName)) { $this->dispatch('error', 'A service is required.'); return; } // Drop any nested arrays left from broken wire:model paths (e.g. service names with dots). $this->serviceRedirects = collect($this->serviceRedirects) ->filter(fn ($value) => is_string($value) || is_numeric($value)) ->map(fn ($value) => $this->normalizeRedirect(is_string($value) ? $value : (string) $value)) ->all(); $wireKey = $this->serviceRedirectWireKey($serviceName); if (! array_key_exists($wireKey, $this->serviceRedirects)) { $this->serviceRedirects[$wireKey] = 'both'; } $this->validateOnly("serviceRedirects.{$wireKey}"); $redirect = $this->normalizeRedirect($this->serviceRedirects[$wireKey] ?? null); $this->serviceRedirects[$wireKey] = $redirect; $this->pendingRedirectService = $serviceName; // Promote the optional www/non-www suggestion to a real domain for redirects. $addedDomains = []; if (in_array($redirect, ['www', 'non-www'], true)) { $domainsBeforePairing = $this->currentDomainList($serviceName); if (! $this->ensureWwwNonWwwPairsConfigured($serviceName)) { return; } // Ensure we re-read domains after pair save before writing redirect. $this->application->refresh(); $addedDomains = $this->currentDomainList($serviceName)->diff($domainsBeforePairing)->values()->all(); } $allDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; if (! is_array($allDomains)) { $allDomains = []; } $existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : []; $allDomains[$serviceName] = array_merge($existing, [ 'redirect' => $redirect, ]); // Keep domain key present when only redirect is set. if (! array_key_exists('domain', $allDomains[$serviceName])) { $allDomains[$serviceName]['domain'] = null; } $this->application->docker_compose_domains = json_encode($allDomains); $this->application->save(); $domains = $this->currentDomainList($serviceName); if (! $this->assertRedirectDomainsPresent($redirect, $domains)) { return; } $this->pendingAction = null; $this->pendingRedirectService = null; $this->forceSaveDomains = false; $this->resetDefaultLabels(); if ($this->notifyRedirectUpdate) { $this->dispatch('success', "Redirect updated for {$serviceName}."); } $this->refreshDomains(); $this->checkUrlsDns($addedDomains, $serviceName); $this->pruneDomainDnsStatusesToCurrentDomains(); } catch (\Throwable $e) { handleError($e, $this); } } /** * @param Collection $domains */ protected function assertRedirectDomainsPresent(string $redirect, Collection $domains): bool { if (! in_array($redirect, ['www', 'non-www'], true)) { return true; } $hasWww = $domains->filter( fn ($fqdn) => str_starts_with(strtolower((string) $this->domainHost((string) $fqdn)), 'www.') )->count(); $hasNonWww = $domains->filter( function ($fqdn) { $host = strtolower((string) $this->domainHost((string) $fqdn)); return $host !== '' && ! str_starts_with($host, 'www.'); } )->count(); $dnsHint = dnsMismatchGuidanceMessage( $this->dnsTargetLabel() ?? $this->serverIp, $this->serverIp, ); // Redirects need both hosts: canonical target + source the proxy redirects from. if ($hasWww === 0 || $hasNonWww === 0) { $missing = $hasWww === 0 ? 'www' : 'non-www'; $this->dispatch( 'error', "Redirect requires both www and non-www domains, but the {$missing} host could not be added automatically (e.g. only IP/sslip hosts).

Please add the {$missing} domain manually ({$dnsHint})." ); return false; } return true; } /** * When saved redirect is www/non-www, ensure missing counterparts exist as real domains * (not suggestion rows the user must click Add domain for). * * @return array newly added domain URLs */ protected function syncRedirectDomainPairs(?string $serviceName = null): array { if ($this->labelsAreWritable) { return []; } $user = auth()->user(); if ($user === null || ! $user->can('update', $this->application)) { return []; } if ($this->isCompose && $serviceName === null) { $added = []; $serviceNames = $this->composeServices; $domains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; if (is_array($domains)) { foreach (array_keys($domains) as $name) { if (! in_array($name, $serviceNames, true)) { $serviceNames[] = $name; } } } foreach ($serviceNames as $name) { $added = array_merge($added, $this->syncRedirectDomainPairs($name)); } return array_values(array_unique($added)); } $redirect = $this->savedRedirectForService($serviceName); if (! in_array($redirect, ['www', 'non-www'], true)) { return []; } $before = $this->currentDomainList($serviceName)->all(); if (! $this->ensureWwwNonWwwPairsConfigured($serviceName)) { return []; } $this->application->refresh(); $after = $this->currentDomainList($serviceName); return $after->reject(fn (string $url) => in_array($url, $before, true))->values()->all(); } protected function savedRedirectForService(?string $serviceName): string { if ($this->isCompose && filled($serviceName)) { $domains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; $entry = is_array($domains) ? ($domains[$serviceName] ?? null) : null; $stored = is_array($entry) ? ($entry['redirect'] ?? null) : null; return $this->normalizeRedirect(is_string($stored) ? $stored : null); } return $this->normalizeRedirect($this->application->redirect ?? null); } /** * Persist missing www/non-www counterparts as normal domains (not suggestions). * * @return bool false when save was blocked (e.g. domain conflict modal shown) */ protected function ensureWwwNonWwwPairsConfigured(?string $serviceName = null): bool { $current = $this->currentDomainList($serviceName); $knownHosts = []; foreach ($current as $url) { $host = $this->domainHost($url); if ($host !== null) { $knownHosts[strtolower($host)] = true; } } $toAdd = collect(); foreach ($current as $url) { // Include sslip/nip so compose/dev hosts can still get redirect pairs. $counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true); if ($counterpart === null) { continue; } $counterpartHost = $this->domainHost($counterpart); if ($counterpartHost === null) { continue; } $hostKey = strtolower($counterpartHost); if (isset($knownHosts[$hostKey])) { continue; } $knownHosts[$hostKey] = true; $toAdd->push($counterpart); } if ($toAdd->isEmpty()) { return true; } $merged = $current->merge($toAdd)->unique()->values(); $this->pendingAction = 'redirect'; $this->pendingRedirectService = $serviceName; // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. if (! $this->saveDomainList($merged, $serviceName)) { return false; } $this->pendingAction = null; $this->pendingRedirectService = null; return true; } protected function domainHost(string $url): ?string { try { $host = parse_url($url, PHP_URL_HOST); return is_string($host) && $host !== '' ? $host : null; } catch (\Throwable) { return null; } } /** * Build the www/non-www counterpart URL for a host. * * @param bool $forRedirectPairing When true, also pair sslip/nip hosts so www↔non-www * redirects can be configured (suggestions still skip them). */ protected function wwwCounterpartUrl(string $url, bool $forRedirectPairing = false): ?string { $host = $this->domainHost($url); if ($host === null) { return null; } $lowerHost = strtolower($host); // Always skip bare IPs and localhost. if (filter_var($host, FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { return null; } // Optional suggestions skip auto-generated hosts; redirect pairing includes them so // Set Direction can promote the missing side to a real domain. if ( ! $forRedirectPairing && (str_contains($lowerHost, 'sslip.io') || str_contains($lowerHost, 'nip.io')) ) { return null; } $counterpartHost = str_starts_with($lowerHost, 'www.') ? substr($host, 4) : 'www.'.$host; if ($counterpartHost === '' || $counterpartHost === $host) { return null; } $parts = parse_url($url); if ($parts === false) { return null; } $scheme = $parts['scheme'] ?? 'https'; $port = isset($parts['port']) ? ':'.$parts['port'] : ''; $path = $parts['path'] ?? ''; $query = isset($parts['query']) ? '?'.$parts['query'] : ''; $fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : ''; return "{$scheme}://{$counterpartHost}{$port}{$path}{$query}{$fragment}"; } /** * @return Collection */ protected function currentDomainList(?string $serviceName = null): Collection { if ($this->isCompose) { $domains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; if (! is_array($domains)) { $domains = []; } $domainString = is_array($domains[$serviceName] ?? null) ? ($domains[$serviceName]['domain'] ?? null) : null; return collect($this->splitDomains(is_string($domainString) ? $domainString : null)); } return collect($this->splitDomains($this->application->fqdn)); } /** * @param Collection $domains */ protected function saveDomainList( Collection $domains, ?string $serviceName = null, bool $checkConflicts = true, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); if ($domainString) { $errors = ValidationPatterns::validateApplicationDomains($domainString); if ($errors !== []) { $this->dispatch('error', $errors[0]); return false; } } if ($this->isCompose) { if (blank($serviceName)) { $this->dispatch('error', 'A service is required for compose domains.'); return false; } $allDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; if (! is_array($allDomains)) { $allDomains = []; } $existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : []; // Preserve stored redirect only — pending Direction dropdown values must not // persist until setServiceRedirect() runs. $allDomains[$serviceName] = array_merge($existing, [ 'domain' => $domainString, ]); $this->application->docker_compose_domains = json_encode($allDomains); $this->application->fqdn = null; } else { $this->application->fqdn = $domainString; } if ($checkConflicts && ! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $this->application); if ($result['hasConflicts']) { $this->domainConflicts = $result['conflicts']; $this->showDomainConflictModal = true; // Revert in-memory mutation so a cancelled conflict leaves model clean $this->application->refresh(); return false; } } else { $this->forceSaveDomains = false; } $warning = sslipDomainWarning($domainString ?? ''); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } $this->application->save(); $this->resetDefaultLabels(); $this->dispatch('configurationChanged'); return true; } protected function resetDefaultLabels(): void { try { if (! $this->application->settings->is_container_label_readonly_enabled) { return; } $customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); $this->application->custom_labels = base64_encode($customLabels); $this->application->save(); $this->dispatch('configurationChanged'); } catch (\Throwable $e) { handleError($e, $this); } } public function render() { return view('livewire.project.application.domains'); } }