From 554b79e8dd568eeb947369ce3f40e367b87309de Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:00:00 +0200 Subject: [PATCH] feat: add Traefik ACME cert UI and shared managed DNS record ownership - Proxy: list and delete Traefik ACME certificates from the server proxy page via new TraefikAcmeService and Get/DeleteTraefikCertificate actions - DNS: track ownership and cross-resource references for managed DNS records so records are only deleted when no longer referenced; release records asynchronously on resource deletion via ReleaseManagedDnsRecordsJob and ManagedDnsRecordCleanup; harden Cloudflare provider deletion results - Databases: fail closed on start when prerequisites or the CA certificate are missing (DatabaseStartException, Server::ensureCaCertificate) and clean up stale start activities via ResourceStartActivity - Webhooks: throttle repeated manual webhook signature failures for GitHub, GitLab, Gitea and Bitbucket - Deployments: improve compose build-context handling and compose file load error reporting - Install scripts: rework terminal UI output in install.sh (stable and nightly) - Misc: settings sidebar accordion fixes, log drain toggle rollback, add Serverside to README sponsors - Add migrations and tests covering the above --- README.md | 1 + app/Actions/Database/RestartDatabase.php | 4 + app/Actions/Database/StartDatabase.php | 46 +- app/Actions/Database/StartDragonfly.php | 14 +- app/Actions/Database/StartKeydb.php | 14 +- app/Actions/Database/StartMariadb.php | 14 +- app/Actions/Database/StartMongodb.php | 14 +- app/Actions/Database/StartMysql.php | 14 +- app/Actions/Database/StartPostgresql.php | 14 +- app/Actions/Database/StartRedis.php | 14 +- .../Proxy/DeleteTraefikCertificate.php | 57 ++ app/Actions/Proxy/GetTraefikCertificates.php | 55 ++ app/Actions/Service/DeleteService.php | 10 +- app/Console/Commands/Init.php | 11 + app/Enums/ManagedDnsDeletionResult.php | 26 + app/Exceptions/DatabaseStartException.php | 22 + app/Exceptions/Handler.php | 1 + app/Http/Controllers/Webhook/Bitbucket.php | 8 +- .../MatchesManualWebhookApplications.php | 18 +- .../ThrottlesManualWebhookFailures.php | 46 ++ app/Http/Controllers/Webhook/Gitea.php | 10 +- app/Http/Controllers/Webhook/Github.php | 10 +- app/Http/Controllers/Webhook/Gitlab.php | 20 +- app/Jobs/ApplicationDeploymentJob.php | 72 +-- app/Jobs/DatabaseStartJob.php | 18 +- app/Jobs/ReleaseManagedDnsRecordsJob.php | 46 ++ .../Concerns/InteractsWithDnsProviders.php | 48 +- app/Livewire/Project/Application/Domains.php | 6 +- .../Project/Application/PreviewDomains.php | 2 +- app/Livewire/Project/Database/Heading.php | 25 +- app/Livewire/Project/Service/Domains.php | 6 +- app/Livewire/Project/Service/Heading.php | 13 +- app/Livewire/Server/LogDrains.php | 2 +- app/Livewire/Server/Proxy.php | 33 ++ app/Models/Application.php | 140 +++-- app/Models/ApplicationPreview.php | 3 +- app/Models/ManagedDnsRecord.php | 43 +- app/Models/ManagedDnsRecordReference.php | 25 + app/Models/Server.php | 15 + app/Models/Service.php | 9 +- app/Models/ServiceApplication.php | 3 +- app/Providers/AppServiceProvider.php | 12 + app/Services/Dns/CloudflareDnsProvider.php | 197 +++++-- app/Services/Dns/ManagedDnsRecordCleanup.php | 271 ++++++++++ app/Services/TraefikAcmeService.php | 127 +++++ app/Support/ResourceStartActivity.php | 160 ++++++ app/Traits/ReleasesManagedDnsRecords.php | 52 ++ .../factories/ManagedDnsRecordFactory.php | 9 + ...add_owned_to_managed_dns_records_table.php | 27 + ...te_managed_dns_record_references_table.php | 56 ++ other/nightly/install.sh | 377 +++++++++---- resources/js/settings-sidebar-accordion.js | 8 +- .../configuration-sidebar.blade.php | 2 +- .../database/configuration-sidebar.blade.php | 2 +- .../views/components/server/sidebar.blade.php | 4 +- .../service/configuration-sidebar.blade.php | 2 +- .../project/application/domains.blade.php | 11 + .../project/service/configuration.blade.php | 2 +- .../views/livewire/server/proxy.blade.php | 93 ++++ routes/webhooks.php | 8 +- scripts/install.sh | 379 +++++++++---- .../AdvisorySecurityRegressionTest.php | 22 +- ...ationDeploymentControlVarFilteringTest.php | 2 + tests/Feature/ApplicationDomainsTest.php | 27 +- .../ComposeBuildContextDeploymentTest.php | 159 ++++++ tests/Feature/ComposeFileLoadCommandsTest.php | 75 +++ tests/Feature/ComposeFileLoadErrorTest.php | 79 +++ tests/Feature/DatabaseStartFailClosedTest.php | 238 ++++++++ .../DeleteResourceJobAtomicityTest.php | 31 ++ tests/Feature/DnsProviderManagementTest.php | 63 ++- .../LogDrain/LogDrainToggleRollbackTest.php | 29 + .../Feature/ManagedDnsRecordOwnershipTest.php | 508 ++++++++++++++++++ tests/Feature/ProxyAcmeCertificateUiTest.php | 36 ++ ...ueuedJobsReadFreshInstanceSettingsTest.php | 22 + tests/Feature/ServiceDomainsTest.php | 6 +- .../Feature/SettingsSidebarAccordionTest.php | 18 +- tests/Feature/StaleStartActivityTest.php | 172 ++++++ tests/Feature/Webhook/WebhookHmacTest.php | 168 +++++- tests/Unit/ComposeBuildPathSecurityTest.php | 40 +- tests/Unit/InstallScriptTerminalUiTest.php | 72 +++ tests/Unit/TraefikAcmeServiceTest.php | 75 +++ 81 files changed, 3960 insertions(+), 633 deletions(-) create mode 100644 app/Actions/Proxy/DeleteTraefikCertificate.php create mode 100644 app/Actions/Proxy/GetTraefikCertificates.php create mode 100644 app/Enums/ManagedDnsDeletionResult.php create mode 100644 app/Exceptions/DatabaseStartException.php create mode 100644 app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php create mode 100644 app/Jobs/ReleaseManagedDnsRecordsJob.php create mode 100644 app/Models/ManagedDnsRecordReference.php create mode 100644 app/Services/Dns/ManagedDnsRecordCleanup.php create mode 100644 app/Services/TraefikAcmeService.php create mode 100644 app/Support/ResourceStartActivity.php create mode 100644 app/Traits/ReleasesManagedDnsRecords.php create mode 100644 database/migrations/2026_09_25_154121_add_owned_to_managed_dns_records_table.php create mode 100644 database/migrations/2026_09_25_154122_create_managed_dns_record_references_table.php create mode 100644 tests/Feature/ComposeBuildContextDeploymentTest.php create mode 100644 tests/Feature/ComposeFileLoadCommandsTest.php create mode 100644 tests/Feature/ComposeFileLoadErrorTest.php create mode 100644 tests/Feature/DatabaseStartFailClosedTest.php create mode 100644 tests/Feature/ManagedDnsRecordOwnershipTest.php create mode 100644 tests/Feature/ProxyAcmeCertificateUiTest.php create mode 100644 tests/Feature/QueuedJobsReadFreshInstanceSettingsTest.php create mode 100644 tests/Feature/StaleStartActivityTest.php create mode 100644 tests/Unit/InstallScriptTerminalUiTest.php create mode 100644 tests/Unit/TraefikAcmeServiceTest.php diff --git a/README.md b/README.md index e0a1933f49..3b4a234644 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Thank you so much! ### Small Sponsors +Serverside DarkVPS Open Source Alternatives Onserva diff --git a/app/Actions/Database/RestartDatabase.php b/app/Actions/Database/RestartDatabase.php index 940bc69fb7..f8fef5855c 100644 --- a/app/Actions/Database/RestartDatabase.php +++ b/app/Actions/Database/RestartDatabase.php @@ -22,6 +22,10 @@ class RestartDatabase if (! $server->isFunctional()) { return 'Server is not functional'; } + $prerequisiteError = StartDatabase::prerequisiteError($database); + if ($prerequisiteError !== null) { + return $prerequisiteError; + } StopDatabase::run($database, dockerCleanup: false); return StartDatabase::run($database); diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index cb1c517539..af2b48e893 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -4,6 +4,7 @@ namespace App\Actions\Database; use App\Enums\ActivityTypes; use App\Enums\ProcessStatus; +use App\Exceptions\DatabaseStartException; use App\Jobs\DatabaseStartJob; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; @@ -13,9 +14,11 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Support\ResourceStartActivity; use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Decorators\JobDecorator; use Spatie\Activitylog\Models\Activity; +use Throwable; class StartDatabase { @@ -32,6 +35,10 @@ class StartDatabase if (! $server->isFunctional()) { return 'Server is not functional'; } + $prerequisiteError = self::prerequisiteError($database); + if ($prerequisiteError !== null) { + return $prerequisiteError; + } $database->update([ 'restart_count' => 0, 'last_restart_at' => null, @@ -45,7 +52,7 @@ class StartDatabase 'type_uuid' => $database->uuid, 'status' => ProcessStatus::QUEUED->value, 'team_id' => $server->team_id, - 'operation' => 'database-start', + 'operation' => ResourceStartActivity::DATABASE_START_OPERATION, ]) ->performedOn($database) ->event(ActivityTypes::INLINE->value) @@ -55,13 +62,19 @@ class StartDatabase return 'Database start could not be queued because activity logging is disabled.'; } - DatabaseStartJob::dispatch( - $database->getMorphClass(), - (int) $database->getKey(), - (int) $database->team()->id, - (int) $activity->getKey(), - auth()->id(), - ); + try { + DatabaseStartJob::dispatch( + $database->getMorphClass(), + (int) $database->getKey(), + (int) $database->team()->id, + (int) $activity->getKey(), + auth()->id(), + ); + } catch (Throwable $e) { + ResourceStartActivity::markFailed($activity, 'Database start could not be queued.'); + + throw $e; + } if ($database->is_public && $database->public_port) { StartDatabaseProxy::dispatch($database); @@ -69,4 +82,21 @@ class StartDatabase return $activity; } + + /** + * Check prerequisites that would make the queued start fail, so the user gets the + * error immediately instead of a queued start that can only fail later. + */ + public static function prerequisiteError(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database): ?string + { + if (! $database->enable_ssl) { + return null; + } + + if (! $database->destination->server->ensureCaCertificate()) { + return DatabaseStartException::missingCaCertificate()->getMessage(); + } + + return null; + } } diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index a4c1540206..1c4f0ca486 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneDragonfly; @@ -59,18 +60,7 @@ class StartDragonfly $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } + $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index c10723b59e..1b434345aa 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneKeydb; @@ -59,18 +60,7 @@ class StartKeydb $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } + $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 6eb73f65b3..0479703dae 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneMariadb; @@ -59,18 +60,7 @@ class StartMariadb $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } + $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index f85d0c86fa..388bda0b30 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneMongodb; @@ -66,18 +67,7 @@ class StartMongodb $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } + $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); $this->ssl_certificate = $this->database->sslCertificates()->first(); if (! $this->ssl_certificate) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index e69993323e..ff06e0f464 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneMysql; @@ -61,18 +62,7 @@ class StartMysql $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } + $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 18e5413200..940e1abdde 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandalonePostgresql; @@ -65,18 +66,7 @@ class StartPostgresql $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } + $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 580738635d..2fb74e3e45 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneRedis; @@ -61,18 +62,7 @@ class StartRedis $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } + $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Proxy/DeleteTraefikCertificate.php b/app/Actions/Proxy/DeleteTraefikCertificate.php new file mode 100644 index 0000000000..7ce79dca18 --- /dev/null +++ b/app/Actions/Proxy/DeleteTraefikCertificate.php @@ -0,0 +1,57 @@ +proxyType() !== ProxyTypes::TRAEFIK->value) { + throw new RuntimeException('TLS certificates can only be managed for Traefik proxies.'); + } + + $contents = $this->getTraefikCertificates->contents($server); + if ($contents === null) { + throw new RuntimeException('The Traefik ACME file could not be found.'); + } + + $certificate = collect($this->acmeService->certificates($contents)) + ->firstWhere('id', $certificateId); + if ($certificate === null) { + throw new RuntimeException('The selected certificate could not be found.'); + } + + $updatedContents = $this->acmeService->deleteCertificate( + $contents, + $certificate['resolver'], + $certificateId, + ); + + $path = rtrim($server->proxyPath(), '/').'/acme.json'; + $temporaryPath = $path.'.coolify-'.bin2hex(random_bytes(8)); + $encodedContents = base64_encode($updatedContents); + $script = sprintf( + 'set -e; umask 077; printf %%s %s | base64 -d > %s; chmod 600 %s; mv -- %s %s', + escapeshellarg($encodedContents), + escapeshellarg($temporaryPath), + escapeshellarg($temporaryPath), + escapeshellarg($temporaryPath), + escapeshellarg($path), + ); + + instant_remote_process(['sh -c '.escapeshellarg($script)], $server); + } +} diff --git a/app/Actions/Proxy/GetTraefikCertificates.php b/app/Actions/Proxy/GetTraefikCertificates.php new file mode 100644 index 0000000000..7ca7f11bc3 --- /dev/null +++ b/app/Actions/Proxy/GetTraefikCertificates.php @@ -0,0 +1,55 @@ +, store: ?string, expires_at: ?string}> */ + public function handle(Server $server): array + { + if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) { + return []; + } + + $contents = $this->contents($server); + + return $contents === null ? [] : $this->acmeService->certificates($contents); + } + + public function contents(Server $server): ?string + { + $path = escapeshellarg(rtrim($server->proxyPath(), '/').'/acme.json'); + $readLimit = self::MAX_FILE_SIZE_BYTES + 1; + $tooLargeMarker = '__COOLIFY_ACME_FILE_TOO_LARGE__'; + $output = instant_remote_process([ + "if [ ! -f {$path} ]; then exit 0; elif [ \"\$(wc -c < {$path})\" -gt ".self::MAX_FILE_SIZE_BYTES." ]; then echo '{$tooLargeMarker}'; else head -c {$readLimit} {$path} | base64 | tr -d '\\n'; fi", + ], $server, false); + + if ($output === null || trim($output) === '') { + return null; + } + + if (trim($output) === $tooLargeMarker) { + throw new RuntimeException('The Traefik ACME file exceeds the 10 MiB size limit.'); + } + + $contents = base64_decode(trim($output), true); + if ($contents === false || strlen($contents) > self::MAX_FILE_SIZE_BYTES) { + throw new RuntimeException('The Traefik ACME file could not be read safely.'); + } + + return $contents; + } +} diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index a5f99bf391..6c843e57de 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -51,14 +51,16 @@ class DeleteService throw new RuntimeException('Server is not functional.'); } - $this->removeContainers($service, $resource->id); + $this->removeContainers($service, $resource); } - private function removeContainers(Service $service, ?int $subresourceId = null): void + private function removeContainers(Service $service, ServiceApplication|ServiceDatabase|null $subresource = null): void { $filters = "--filter 'label=coolify.serviceId={$service->id}'"; - if ($subresourceId !== null) { - $filters .= " --filter 'label=coolify.service.subId={$subresourceId}'"; + if ($subresource !== null) { + // Applications and databases are separate tables, so an id alone can match the other type. + $subType = $subresource instanceof ServiceDatabase ? 'database' : 'application'; + $filters .= " --filter 'label=coolify.service.subId={$subresource->id}' --filter 'label=coolify.service.subType={$subType}'"; } $command = "container_ids=\$(docker ps -aq {$filters}); [ -z \"\$container_ids\" ] || docker rm -f \$container_ids"; diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index da379be23c..4f3a808df1 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -15,6 +15,7 @@ use App\Models\ScheduledTaskExecution; use App\Models\Server; use App\Models\StandalonePostgresql; use App\Models\User; +use App\Support\ResourceStartActivity; use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; @@ -105,6 +106,16 @@ class Init extends Command echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n"; } + try { + $interruptedStartCount = ResourceStartActivity::failInterrupted(); + + if ($interruptedStartCount > 0) { + echo "Marked {$interruptedStartCount} interrupted database/service starts as failed\n"; + } + } catch (\Throwable $e) { + echo "Could not cleanup interrupted database/service starts: {$e->getMessage()}\n"; + } + try { $updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([ 'status' => 'failed', diff --git a/app/Enums/ManagedDnsDeletionResult.php b/app/Enums/ManagedDnsDeletionResult.php new file mode 100644 index 0000000000..a5b6311fd6 --- /dev/null +++ b/app/Enums/ManagedDnsDeletionResult.php @@ -0,0 +1,26 @@ +hasTooManyManualWebhookFailures($request, 'bitbucket')) { + return $this->tooManyManualWebhookFailuresResponse($request, 'bitbucket'); + } + try { $return_payloads = collect([]); $payload = $request->collect(); @@ -74,7 +78,7 @@ class Bitbucket extends Controller } $applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { - return response([$this->unauthenticatedManualWebhookFailurePayload()]); + return $this->unauthenticatedManualWebhookResponse($request, 'bitbucket'); } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket'); @@ -275,7 +279,7 @@ class Bitbucket extends Controller } } - return $this->manualWebhookResponse($return_payloads); + return $this->manualWebhookResponse($return_payloads, $request, 'bitbucket'); } catch (Exception $e) { return handleError($e); } diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index 7aa2d6b48d..080b4a1725 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -4,11 +4,14 @@ namespace App\Http\Controllers\Webhook\Concerns; use App\Models\Application; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Collection; trait MatchesManualWebhookApplications { + use ThrottlesManualWebhookFailures; + protected function manualWebhookRepositoryFullName(mixed $fullName): ?string { if (! is_string($fullName)) { @@ -62,12 +65,23 @@ trait MatchesManualWebhookApplications ]; } - protected function manualWebhookResponse(Collection $payloads): Response + /** + * Respond to a delivery that could not be authenticated (no matching + * application or no signature) and count it as a failed attempt. + */ + protected function unauthenticatedManualWebhookResponse(Request $request, string $provider): Response + { + $this->recordManualWebhookFailure($request, $provider); + + return response([$this->unauthenticatedManualWebhookFailurePayload()]); + } + + protected function manualWebhookResponse(Collection $payloads, Request $request, string $provider): Response { $failure = $this->unauthenticatedManualWebhookFailurePayload(); $authorizedPayloads = $payloads->reject(fn (array $payload): bool => $payload === $failure)->values(); if ($authorizedPayloads->isEmpty() && $payloads->isNotEmpty()) { - return response([$failure]); + return $this->unauthenticatedManualWebhookResponse($request, $provider); } return response($authorizedPayloads); diff --git a/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php b/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php new file mode 100644 index 0000000000..fe2832eb1a --- /dev/null +++ b/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php @@ -0,0 +1,46 @@ +manualWebhookFailureRateLimitKey($request, $provider), self::MANUAL_WEBHOOK_MAX_FAILURES); + } + + protected function tooManyManualWebhookFailuresResponse(Request $request, string $provider): Response + { + $retryAfter = RateLimiter::availableIn($this->manualWebhookFailureRateLimitKey($request, $provider)); + + return response([ + 'status' => 'failed', + 'message' => 'Too many failed webhook authentication attempts. Try again later.', + ], 429)->header('Retry-After', (string) max($retryAfter, 1)); + } + + protected function recordManualWebhookFailure(Request $request, string $provider): void + { + RateLimiter::hit($this->manualWebhookFailureRateLimitKey($request, $provider), self::MANUAL_WEBHOOK_FAILURE_DECAY_SECONDS); + } +} diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 97051d5beb..975e990973 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -21,6 +21,10 @@ class Gitea extends Controller public function manual(Request $request) { + if ($this->hasTooManyManualWebhookFailures($request, 'gitea')) { + return $this->tooManyManualWebhookFailuresResponse($request, 'gitea'); + } + try { $return_payloads = collect([]); $x_gitea_delivery = request()->header('X-Gitea-Delivery'); @@ -69,13 +73,13 @@ class Gitea extends Controller if ($x_gitea_event === 'push') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { - return response([$this->unauthenticatedManualWebhookFailurePayload()]); + return $this->unauthenticatedManualWebhookResponse($request, 'gitea'); } } if ($x_gitea_event === 'pull_request') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { - return response([$this->unauthenticatedManualWebhookFailurePayload()]); + return $this->unauthenticatedManualWebhookResponse($request, 'gitea'); } } foreach ($applications as $application) { @@ -281,7 +285,7 @@ class Gitea extends Controller } } - return $this->manualWebhookResponse($return_payloads); + return $this->manualWebhookResponse($return_payloads, $request, 'gitea'); } catch (Exception $e) { return handleError($e); } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 3272b27983..e00c9cf4a4 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -25,6 +25,10 @@ class Github extends Controller public function manual(Request $request) { + if ($this->hasTooManyManualWebhookFailures($request, 'github')) { + return $this->tooManyManualWebhookFailuresResponse($request, 'github'); + } + try { $return_payloads = collect([]); $x_github_delivery = request()->header('X-GitHub-Delivery'); @@ -79,7 +83,7 @@ class Github extends Controller if ($x_github_event === 'push') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { - return response([$this->unauthenticatedManualWebhookFailurePayload()]); + return $this->unauthenticatedManualWebhookResponse($request, 'github'); } } if ($x_github_event === 'pull_request') { @@ -88,7 +92,7 @@ class Github extends Controller } $applications = $this->manualWebhookApplications($applications, $full_name); if ($applications->isEmpty()) { - return response([$this->unauthenticatedManualWebhookFailurePayload()]); + return $this->unauthenticatedManualWebhookResponse($request, 'github'); } } $applicationsByServer = $applications->groupBy(function ($app) { @@ -239,7 +243,7 @@ class Github extends Controller } } - return $this->manualWebhookResponse($return_payloads); + return $this->manualWebhookResponse($return_payloads, $request, 'github'); } catch (Exception $e) { return handleError($e); } diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 8ac1cab18a..e95a98b230 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -336,6 +336,10 @@ class Gitlab extends Controller public function manual(Request $request) { + if ($this->hasTooManyManualWebhookFailures($request, 'gitlab')) { + return $this->tooManyManualWebhookFailuresResponse($request, 'gitlab'); + } + try { $return_payloads = collect([]); $payload = $request->collect(); @@ -356,12 +360,8 @@ class Gitlab extends Controller auditLogWebhookFailure('gitlab', 'webhook_token_missing', [ 'event' => $x_gitlab_event, ]); - $return_payloads->push([ - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); - return response($return_payloads); + return $this->unauthenticatedManualWebhookResponse($request, 'gitlab'); } if ($x_gitlab_event === 'push') { @@ -416,17 +416,13 @@ class Gitlab extends Controller if ($x_gitlab_event === 'push') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { - $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); - - return response($return_payloads); + return $this->unauthenticatedManualWebhookResponse($request, 'gitlab'); } } if ($x_gitlab_event === 'merge_request') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { - $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); - - return response($return_payloads); + return $this->unauthenticatedManualWebhookResponse($request, 'gitlab'); } } foreach ($applications as $application) { @@ -635,7 +631,7 @@ class Gitlab extends Controller } } - return $this->manualWebhookResponse($return_payloads); + return $this->manualWebhookResponse($return_payloads, $request, 'gitlab'); } catch (Exception $e) { return handleError($e); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index fb70c9c9cc..5484147409 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -758,8 +758,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return; } - $this->validateComposeBuildPaths($composeFile); - // Add build secrets to compose file if enabled and BuildKit is supported if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { $composeFile = $this->add_build_secrets_to_compose($composeFile); @@ -4915,7 +4913,13 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } $dockerfilePath = $this->resolveComposeDockerfilePath($service['build']); - $fullDockerfilePath = escapeshellarg("{$this->workdir}/{$dockerfilePath}"); + if ($dockerfilePath === null) { + $this->application_deployment_queue->addLogEntry("The build context of service {$serviceName} is remote or uses variables, skipping ARG injection."); + + continue; + } + // Compose resolves relative paths from the project directory (the workdir). + $fullDockerfilePath = escapeshellarg(str_starts_with($dockerfilePath, '/') ? $dockerfilePath : "{$this->workdir}/{$dockerfilePath}"); // BusyBox realpath in the helper image accepts no options; a missing file prints nothing. $this->execute_remote_command([ @@ -4924,8 +4928,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); 'save' => 'dockerfile_check_'.$serviceName, ]); + // A monorepo context may leave the base directory, but never the cloned repository. $resolvedDockerfilePath = str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString(); - if (! str_starts_with($resolvedDockerfilePath, "{$this->workdir}/")) { + if (! str_starts_with($resolvedDockerfilePath, "{$this->basedir}/")) { $this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection."); continue; @@ -5042,60 +5047,29 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } - private function validateComposeBuildPaths(array|Collection $composeFile): void - { - foreach (data_get($composeFile, 'services', []) as $service) { - if (isset($service['build'])) { - $this->resolveComposeDockerfilePath($service['build']); - } - } - } - - private function resolveComposeDockerfilePath(mixed $build): string + /** + * Returns the Dockerfile path of a Compose build as written (relative to the project directory, + * or absolute), or null when Coolify cannot inspect it locally: a remote git context, a path that + * Compose fills in from variables, or an inline Dockerfile. The path is untrusted: the caller + * quotes it and only uses the resolved file when it is inside the cloned repository. + */ + private function resolveComposeDockerfilePath(mixed $build): ?string { if (! is_string($build) && ! is_array($build)) { - throw new \RuntimeException('Invalid Docker Compose build definition.'); + return null; } $context = is_string($build) ? $build : data_get($build, 'context', '.'); $dockerfile = is_array($build) ? data_get($build, 'dockerfile', 'Dockerfile') : 'Dockerfile'; - if (! is_string($context) || ! is_string($dockerfile)) { - throw new \RuntimeException('Invalid Docker Compose build path: context and dockerfile must be strings.'); + if (! is_string($context) || ! is_string($dockerfile) || $context === '' || (is_array($build) && array_key_exists('dockerfile_inline', $build))) { + return null; + } + if (str_contains($context.$dockerfile, '$') || preg_match('~^[a-z][a-z0-9+.-]*://|^git@~i', $context) === 1) { + return null; } - $this->validateComposeBuildPath($context, 'context'); - $this->validateComposeBuildPath($dockerfile, 'dockerfile'); - - return $this->normalizeComposeBuildPath("{$context}/{$dockerfile}", 'dockerfile'); - } - - private function validateComposeBuildPath(string $path, string $fieldName): void - { - if ($path === '' || str_starts_with($path, '/') || ! preg_match('/^[a-zA-Z0-9._\-\/@+]+$/', $path)) { - throw new \RuntimeException("Invalid Docker Compose build.{$fieldName} path."); - } - } - - private function normalizeComposeBuildPath(string $path, string $fieldName): string - { - $segments = []; - foreach (explode('/', $path) as $segment) { - if ($segment === '' || $segment === '.') { - continue; - } - if ($segment === '..') { - if ($segments === []) { - throw new \RuntimeException("Invalid Docker Compose build.{$fieldName} path: path traversal outside the repository."); - } - array_pop($segments); - - continue; - } - $segments[] = $segment; - } - - return $segments === [] ? '.' : implode('/', $segments); + return str_starts_with($dockerfile, '/') ? $dockerfile : rtrim($context, '/').'/'.$dockerfile; } private function add_build_secrets_to_compose($composeFile) diff --git a/app/Jobs/DatabaseStartJob.php b/app/Jobs/DatabaseStartJob.php index e21ee38c61..e520c79dfe 100644 --- a/app/Jobs/DatabaseStartJob.php +++ b/app/Jobs/DatabaseStartJob.php @@ -12,6 +12,7 @@ use App\Actions\Database\StartPostgresql; use App\Actions\Database\StartRedis; use App\Enums\ProcessStatus; use App\Events\DatabaseStatusChanged; +use App\Exceptions\DatabaseStartException; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -20,6 +21,7 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Support\ResourceStartActivity; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -53,7 +55,7 @@ class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue abort_unless((int) $database->team()->id === $this->teamId, 403); $activity = Activity::query()->findOrFail($this->activityId); - match ($database->getMorphClass()) { + $result = match ($database->getMorphClass()) { StandalonePostgresql::class => StartPostgresql::run($database, $activity), StandaloneRedis::class => StartRedis::run($database, $activity), StandaloneMongodb::class => StartMongodb::run($database, $activity), @@ -64,6 +66,10 @@ class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue StandaloneClickhouse::class => StartClickhouse::run($database, $activity), }; + if (! $result instanceof Activity || data_get($result, 'properties.status') !== ProcessStatus::FINISHED->value) { + throw DatabaseStartException::startCommandsDidNotRun(); + } + event(new DatabaseStatusChanged($this->userId)); } @@ -75,12 +81,10 @@ class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue return; } - $activity->properties = $activity->properties->merge([ - 'status' => ProcessStatus::ERROR->value, - 'error' => 'Database start failed.', - 'failed_at' => now()->toIso8601String(), - ]); - $activity->save(); + ResourceStartActivity::markFailed( + $activity, + $exception instanceof DatabaseStartException ? $exception->getMessage() : 'Database start failed.', + ); } finally { event(new DatabaseStatusChanged($this->userId)); } diff --git a/app/Jobs/ReleaseManagedDnsRecordsJob.php b/app/Jobs/ReleaseManagedDnsRecordsJob.php new file mode 100644 index 0000000000..6519da83af --- /dev/null +++ b/app/Jobs/ReleaseManagedDnsRecordsJob.php @@ -0,0 +1,46 @@ +releaseResource($this->resourceType, $this->resourceId)) { + return; + } + + if ($this->attempts() < self::MAX_ATTEMPTS) { + $this->release(60 * $this->attempts()); + + return; + } + + Log::warning('Managed DNS records of a deleted resource could not be cleaned up; they were left in place.', [ + 'resource_type' => $this->resourceType, + 'resource_id' => $this->resourceId, + ]); + } +} diff --git a/app/Livewire/Concerns/InteractsWithDnsProviders.php b/app/Livewire/Concerns/InteractsWithDnsProviders.php index 2f7e86c7ff..da47073011 100644 --- a/app/Livewire/Concerns/InteractsWithDnsProviders.php +++ b/app/Livewire/Concerns/InteractsWithDnsProviders.php @@ -2,11 +2,13 @@ namespace App\Livewire\Concerns; +use App\Enums\ManagedDnsDeletionResult; use App\Exceptions\DnsRecordConflictException; use App\Jobs\ConfigureDnsRecordJob; use App\Models\DnsProviderZone; use App\Models\ManagedDnsRecord; use App\Services\Dns\CloudflareDnsProvider; +use App\Services\Dns\ManagedDnsRecordCleanup; use Illuminate\Database\Eloquent\Model; trait InteractsWithDnsProviders @@ -48,7 +50,8 @@ trait InteractsWithDnsProviders return; } try { - $cloudflare->createRecord($zone, $hostname, $content, $this->dnsResourceForHostname($hostname)); + $record = $cloudflare->createRecord($zone, $hostname, $content, $this->dnsResourceForHostname($hostname)); + $this->referenceDnsRecordFromAllResources($record, $hostname); $this->markDnsManaged($hostname, $zone->integrationToken->name); $this->dispatch('success', "DNS record created for {$hostname}."); $this->loadDnsProviderProposals(); @@ -146,7 +149,7 @@ trait InteractsWithDnsProviders return; } try { - app(CloudflareDnsProvider::class)->replaceRecord( + $record = app(CloudflareDnsProvider::class)->replaceRecord( $zone, (string) ($conflict['record_id'] ?? ''), $hostname, @@ -154,6 +157,7 @@ trait InteractsWithDnsProviders $this->dnsResourceForHostname($hostname), (string) ($conflict['current'] ?? ''), ); + $this->referenceDnsRecordFromAllResources($record, $hostname); unset($this->dnsProviderConflicts[$key]); $this->dispatch('success', "DNS record replaced for {$hostname}."); $this->loadDnsProviderProposals(); @@ -220,36 +224,46 @@ trait InteractsWithDnsProviders $this->dispatch('error', "DNS record could not be added for {$event['hostname']}: {$event['message']}"); } - protected function deleteManagedDnsForUrl(string $url): void + /** + * Releases the resource's reference to the managed DNS record of a removed URL. The provider record is deleted only when + * $deleteRecord is set, Coolify created it, and no other resource or URL (in any team) still uses the hostname. + */ + protected function releaseManagedDnsForUrl(string $url, ?Model $resource = null, bool $deleteRecord = true): void { $hostname = parse_url($url, PHP_URL_HOST); if (! is_string($hostname)) { return; } - $resource = $this->dnsResourceForHostname($hostname); + $resource ??= $this->dnsResourceForHostname($hostname); if ($resource === null) { return; } - $record = ManagedDnsRecord::query() - ->where('team_id', currentTeam()->id) - ->where('name', strtolower($hostname)) - ->where('resource_type', $resource->getMorphClass()) - ->where('resource_id', $resource->getKey()) - ->first(); + $result = app(ManagedDnsRecordCleanup::class)->releaseHostname($resource, $hostname, currentTeam()->id, $deleteRecord); - if ($record !== null && ! app(CloudflareDnsProvider::class)->deleteRecord($record)) { - auditLog('ui.dns_record.delete_skipped', [ - 'team_id' => currentTeam()->id, - 'hostname' => $hostname, - 'provider' => 'cloudflare', - 'reason' => 'remote_record_changed', - ], 'warning'); + if ($result === ManagedDnsDeletionResult::ChangedExternally) { $this->dispatch('warning', 'The domain was removed, but its DNS record changed externally and was left untouched.'); + } elseif ($result === ManagedDnsDeletionResult::Failed) { + $this->dispatch('warning', 'The domain was removed, but its DNS record could not be deleted because Cloudflare did not respond. It was left untouched.'); } } + /** + * Service domains can share a hostname across several service applications; each of them references the record. + */ + protected function referenceDnsRecordFromAllResources(ManagedDnsRecord $record, string $hostname): void + { + if (property_exists($this, 'application') || ! property_exists($this, 'service') || $this->service === null) { + return; + } + + $cleanup = app(ManagedDnsRecordCleanup::class); + $this->service->applications()->get() + ->filter(fn (Model $application): bool => in_array(strtolower($hostname), $cleanup->hostnamesOf($application), true)) + ->each(fn (Model $application) => $record->addReference($application)); + } + protected function authorizeDnsProviderChange(): void { $this->authorize('update', property_exists($this, 'application') ? $this->application : $this->service); diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 929c02e93e..246d0c113a 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -211,7 +211,7 @@ class Domains extends Component 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."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record. If you changed it recently, DNS propagation can take some time, so please try again later."), default => $this->dispatch('info', "DNS check skipped for {$host}."), }; } @@ -1601,9 +1601,7 @@ class Domains extends Component return; } - if (in_array('deleteManagedDns', $selectedActions, true)) { - $this->deleteManagedDnsForUrl($url); - } + $this->releaseManagedDnsForUrl($url, $this->application, in_array('deleteManagedDns', $selectedActions, true)); if ($this->editingIndex === $index) { $this->cancelEdit(); diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index 5f454d91ba..9a34d9f10c 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -391,7 +391,7 @@ class PreviewDomains extends Component 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."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record. If you changed it recently, DNS propagation can take some time, so please try again later."), default => $this->dispatch('info', "DNS check skipped for {$host}."), }; } diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 9a7a8634aa..030da438ac 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -6,11 +6,10 @@ use App\Actions\Database\RestartDatabase; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Docker\GetContainersStatus; -use App\Enums\ProcessStatus; use App\Events\ServiceStatusChanged; +use App\Support\ResourceStartActivity; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Spatie\Activitylog\Models\Activity; class Heading extends Component { @@ -79,15 +78,9 @@ class Heading extends Component public function checkDeployments() { try { - $activity = Activity::where('properties->type_uuid', $this->database->uuid)->latest()->first(); - $status = data_get($activity, 'properties.status'); - if ($status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value) { - $this->isDeploymentProgress = true; - $this->runningActivityId = $activity->id; - } else { - $this->isDeploymentProgress = false; - $this->runningActivityId = null; - } + $activity = ResourceStartActivity::latestRunning($this->database->uuid); + $this->isDeploymentProgress = $activity !== null; + $this->runningActivityId = $activity?->id; } catch (\Throwable) { $this->isDeploymentProgress = false; $this->runningActivityId = null; @@ -157,6 +150,11 @@ class Heading extends Component $this->authorize('manage', $this->database); $activity = RestartDatabase::run($this->database); + if (is_string($activity)) { + $this->dispatch('error', $activity); + + return; + } $this->auditDatabaseAction('ui.database.restarted'); $this->markDeploymentRunning($activity); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); @@ -172,6 +170,11 @@ class Heading extends Component $this->authorize('manage', $this->database); $activity = StartDatabase::run($this->database); + if (is_string($activity)) { + $this->dispatch('error', $activity); + + return; + } $this->auditDatabaseAction('ui.database.started'); $this->markDeploymentRunning($activity); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 79c89322a7..92affb9a7d 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -192,7 +192,7 @@ class Domains extends Component 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."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record. If you changed it recently, DNS propagation can take some time, so please try again later."), default => $this->dispatch('info', "DNS check skipped for {$host}."), }; } @@ -1357,9 +1357,7 @@ class Domains extends Component return; } - if (in_array('deleteManagedDns', $selectedActions, true)) { - $this->deleteManagedDnsForUrl($url); - } + $this->releaseManagedDnsForUrl($url, $app, in_array('deleteManagedDns', $selectedActions, true)); $this->forceSaveDomains = false; $this->forceRemovePort = false; diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 33fce9f079..fc6720b218 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -10,6 +10,7 @@ use App\Enums\ProcessStatus; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Support\ResourceStartActivity; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; @@ -103,15 +104,9 @@ class Heading extends Component $this->authorizeService('view'); try { - $activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first(); - $status = data_get($activity, 'properties.status'); - if ($status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value) { - $this->isDeploymentProgress = true; - $this->runningActivityId = $activity->id; - } else { - $this->isDeploymentProgress = false; - $this->runningActivityId = null; - } + $activity = ResourceStartActivity::latestRunning($this->service->uuid); + $this->isDeploymentProgress = $activity !== null; + $this->runningActivityId = $activity?->id; } catch (\Throwable) { $this->isDeploymentProgress = false; $this->runningActivityId = null; diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index cdc4162bba..965232af44 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -128,7 +128,6 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); } - $this->auditLogDrain($this->{$enabledProperty} ? 'enabled' : 'disabled', $type); } } @@ -210,6 +209,7 @@ class LogDrains extends Component } $this->syncData(true); + $this->auditLogDrain($this->{$enabledProperty} ? 'enabled' : 'disabled', $type); if ($this->server->isLogDrainEnabled()) { StartLogDrain::run($this->server); diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index a3f9be2db5..5c3624a2b6 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -2,7 +2,9 @@ namespace App\Livewire\Server; +use App\Actions\Proxy\DeleteTraefikCertificate; use App\Actions\Proxy\GetProxyConfiguration; +use App\Actions\Proxy\GetTraefikCertificates; use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; use App\Models\Server; @@ -26,6 +28,10 @@ class Proxy extends Component public bool $generateExactLabels = false; + public array $traefikCertificates = []; + + public bool $traefikCertificatesLoaded = false; + /** * Cache the versions.json file data in memory for this component instance. * This avoids multiple file reads during a single request/render cycle. @@ -195,6 +201,7 @@ class Proxy extends Component } } +<<<<<<< Updated upstream public function getTraefikVersionForWarningProperty(): ?string { if ($this->server->detected_traefik_version) { @@ -211,6 +218,32 @@ class Proxy extends Component } return $matches[1]; +======= + public function loadTraefikCertificates(): void + { + $this->traefikCertificates = []; + + try { + $this->authorize('view', $this->server); + $this->traefikCertificates = GetTraefikCertificates::run($this->server); + $this->traefikCertificatesLoaded = true; + } catch (\Throwable $e) { + $this->traefikCertificatesLoaded = true; + handleError($e, $this); + } + } + + public function deleteTraefikCertificate(string $certificateId, string $password = ''): void + { + try { + $this->authorize('update', $this->server); + DeleteTraefikCertificate::run($this->server, $certificateId); + $this->traefikCertificates = GetTraefikCertificates::run($this->server); + $this->dispatch('success', 'TLS certificate deleted. Restart Traefik to remove it from the running proxy.'); + } catch (\Throwable $e) { + handleError($e, $this); + } +>>>>>>> Stashed changes } /** diff --git a/app/Models/Application.php b/app/Models/Application.php index 3ef2b28813..bbc37290b4 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -11,19 +11,20 @@ use App\Services\DeploymentConfiguration\ConfigurationDiffer; use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; use App\Traits\Auditable; - use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ReleasesManagedDnsRecords; use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use OpenApi\Attributes as OA; @@ -128,7 +129,7 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { /** @use HasFactory */ - use Auditable, ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; @@ -1459,9 +1460,10 @@ class Application extends BaseModel return application_configuration_dir()."/{$this->uuid}"; } - public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null, ?string $git_ssh_command = null, ?string $gitConfigOptions = null) + public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null, ?string $git_ssh_command = null, ?string $gitConfigOptions = null, ?string $baseDir = null, bool $onlyCheckout = false) { - $baseDir = $this->generateBaseDir($deployment_uuid); + // The folder the repository was cloned into; a checkout on the server passes its own folder. + $baseDir ??= $this->generateBaseDir($deployment_uuid); $escapedBaseDir = escapeshellarg($baseDir); $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; $gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git'; @@ -1487,7 +1489,8 @@ class Application extends BaseModel $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } } - if ($this->settings->is_git_submodules_enabled) { + // A checkout that only reads files (the Compose file) needs neither submodules nor LFS objects. + if ($this->settings->is_git_submodules_enabled && ! $onlyCheckout) { // Check if .gitmodules file exists before running submodule commands $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && if [ -f .gitmodules ]; then"; if ($public) { @@ -1497,7 +1500,7 @@ class Application extends BaseModel $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; $git_clone_command = "{$git_clone_command} {$gitCommand} submodule sync && {$sshCommand} {$gitCommand} submodule update --init --recursive {$submoduleFlags}; fi"; } - if ($this->settings->is_git_lfs_enabled) { + if ($this->settings->is_git_lfs_enabled && ! $onlyCheckout) { $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} lfs pull"; } @@ -1797,7 +1800,7 @@ class Application extends BaseModel $gitConfigOptions = $this->withGitHttpTransportConfig(); $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout); } if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); @@ -1826,7 +1829,7 @@ class Application extends BaseModel } $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout); } if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); @@ -1874,7 +1877,7 @@ class Application extends BaseModel if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOptions); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout); } if ($pull_request_id !== 0) { @@ -1915,7 +1918,7 @@ class Application extends BaseModel if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout); } $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); @@ -1949,7 +1952,7 @@ class Application extends BaseModel if ($gitConfigOptions) { $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); } - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout); if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); @@ -1978,7 +1981,7 @@ class Application extends BaseModel if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout); } $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($pull_request_id !== 0) { @@ -2028,7 +2031,7 @@ class Application extends BaseModel if ($gitConfigOptions) { $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); } - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout); $otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; if ($pull_request_id !== 0) { @@ -2151,32 +2154,44 @@ class Application extends BaseModel } } - public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null) + /** + * Logs a failed git command on the server and returns its last error line for the user. + * Credentials in URLs (such as GitHub App or GitLab tokens) are removed from both, and the + * line is HTML-escaped because error toasts render HTML. + */ + private function gitFailureReason(string $summary, string $errorOutput): string { - // Use provided restore values or capture current values as fallback - $initialDockerComposeLocation = $restoreDockerComposeLocation ?? $this->docker_compose_location; - $initialBaseDirectory = $restoreBaseDirectory ?? $this->base_directory; - if ($isInit && $this->docker_compose_raw) { - return; - } - $uuid = new_public_id(); - ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout'); - $cloneCommand = $this->gitCommandsAsShellCommand($cloneCommand); - $cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand); + $errorOutput = (string) preg_replace('~([a-z][a-z0-9+.-]*://)[^/\s@\'"]+@~i', '$1***@', trim($errorOutput)); + Log::warning($summary, [ + 'application_uuid' => $this->uuid, + 'server_uuid' => $this->destination?->server?->uuid, + 'error' => $errorOutput, + ]); + + $reason = collect(explode("\n", $errorOutput))->map(fn (string $line) => trim($line))->filter()->last(); + + return $reason ? '

Reason: '.e(str($reason)->limit(300)->value()) : ''; + } + + /** + * Commands that check out only the Compose file on the server and print it. They run on the + * server itself, not in a helper container, so the checkout uses an absolute folder in /tmp. + * + * @return Collection + */ + private function composeFileReadCommands(string $uuid, string $gitVersion): Collection + { + $checkoutDir = "/tmp/{$uuid}/checkout"; + ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir); + $cloneCommand = str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)); $workdir = rtrim($this->base_directory, '/'); - $composeFile = $this->docker_compose_location; - $fileList = collect([".$workdir$composeFile"]); - $composeFilePath = escapeshellarg(".$workdir$composeFile"); + $fileList = collect([".{$workdir}{$this->docker_compose_location}"]); + $composeFilePath = escapeshellarg(".{$workdir}{$this->docker_compose_location}"); $composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1; $readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi"; - $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); - if (! $gitRemoteStatus['is_accessible']) { - throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'); - } - $getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false); - $gitVersion = str($getGitVersion)->explode(' ')->last(); - if (version_compare($gitVersion, '2.35.1', '<')) { + $isConeMode = version_compare($gitVersion, '2.35.1', '>='); + if (! $isConeMode) { $fileList = $fileList->map(function ($file) { $parts = explode('/', trim($file, '.')); $paths = collect(); @@ -2190,30 +2205,39 @@ class Application extends BaseModel return $paths; })->flatten()->unique()->values(); - $commands = collect([ - "rm -rf /tmp/{$uuid}", - "mkdir -p /tmp/{$uuid}", - "cd /tmp/{$uuid}", - $cloneCommand, - 'cd checkout', - 'git sparse-checkout init', - "git sparse-checkout set {$fileList->implode(' ')}", - 'git read-tree -mu HEAD', - $readComposeFile, - ]); - } else { - $commands = collect([ - "rm -rf /tmp/{$uuid}", - "mkdir -p /tmp/{$uuid}", - "cd /tmp/{$uuid}", - $cloneCommand, - 'cd checkout', - 'git sparse-checkout init --cone', - "git sparse-checkout set {$fileList->implode(' ')}", - 'git read-tree -mu HEAD', - $readComposeFile, - ]); } + + return collect([ + "rm -rf /tmp/{$uuid}", + "mkdir -p /tmp/{$uuid}", + "cd /tmp/{$uuid}", + // One sh -c line: the non-root sudo parser would turn `&& if ...; then` into invalid `&& sudo if`. + 'sh -c '.escapeshellarg($cloneCommand), + "cd {$checkoutDir}", + $isConeMode ? 'git sparse-checkout init --cone' : 'git sparse-checkout init', + "git sparse-checkout set {$fileList->implode(' ')}", + 'git read-tree -mu HEAD', + $readComposeFile, + ]); + } + + public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null) + { + // Use provided restore values or capture current values as fallback + $initialDockerComposeLocation = $restoreDockerComposeLocation ?? $this->docker_compose_location; + $initialBaseDirectory = $restoreBaseDirectory ?? $this->base_directory; + if ($isInit && $this->docker_compose_raw) { + return; + } + $uuid = new_public_id(); + $workdir = rtrim($this->base_directory, '/'); + $composeFile = $this->docker_compose_location; + $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); + if (! $gitRemoteStatus['is_accessible']) { + throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'.$this->gitFailureReason('Failed to read Git source.', (string) $gitRemoteStatus['error'])); + } + $getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false); + $commands = $this->composeFileReadCommands($uuid, (string) str($getGitVersion)->explode(' ')->last()); try { $composeFileContent = instant_remote_process($commands, $this->destination->server); if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) { @@ -2237,7 +2261,7 @@ class Application extends BaseModel if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) { throw $e; } - throw new RuntimeException('Failed to read the Docker Compose file from the repository.'); + throw new RuntimeException('Failed to read the Docker Compose file from the repository.'.$this->gitFailureReason('Failed to read the Docker Compose file from the repository.', $e->getMessage())); } finally { // Cleanup only - restoration happens in catch block $commands = collect([ diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index d15142b6ad..4c0fc59ae6 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -5,13 +5,14 @@ namespace App\Models; use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use App\Traits\HasRestartLimit; +use App\Traits\ReleasesManagedDnsRecords; use Illuminate\Database\Eloquent\SoftDeletes; use RuntimeException; use Spatie\Url\Url; class ApplicationPreview extends BaseModel { - use HasRestartLimit, SoftDeletes; + use HasRestartLimit, ReleasesManagedDnsRecords, SoftDeletes; protected $attributes = [ 'max_restart_count' => 0, diff --git a/app/Models/ManagedDnsRecord.php b/app/Models/ManagedDnsRecord.php index a025cce0cb..60c0d16713 100644 --- a/app/Models/ManagedDnsRecord.php +++ b/app/Models/ManagedDnsRecord.php @@ -3,18 +3,43 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class ManagedDnsRecord extends BaseModel { use HasFactory; + public const OWNERSHIP_COMMENT_PREFIX = 'managed-by: coolify'; + protected $fillable = [ - 'team_id', 'integration_token_id', 'dns_provider_zone_id', 'resource_type', 'resource_id', - 'provider_record_id', 'type', 'name', 'content', + 'uuid', 'team_id', 'integration_token_id', 'dns_provider_zone_id', + 'provider_record_id', 'type', 'name', 'content', 'owned', ]; + protected function casts(): array + { + return [ + 'owned' => 'boolean', + ]; + } + + /** + * The provider comment that proves Coolify created this record. Records without it are never modified or deleted automatically. + */ + public static function ownershipCommentFor(string $uuid): string + { + $instanceId = substr((string) (config('app.id') ?: 'default'), 0, 40); + + return self::OWNERSHIP_COMMENT_PREFIX." {$instanceId}/{$uuid}"; + } + + public function ownershipComment(): string + { + return self::ownershipCommentFor($this->uuid); + } + public function zone(): BelongsTo { return $this->belongsTo(DnsProviderZone::class, 'dns_provider_zone_id'); @@ -25,8 +50,16 @@ class ManagedDnsRecord extends BaseModel return $this->belongsTo(IntegrationToken::class); } - public function resource(): MorphTo + public function references(): HasMany { - return $this->morphTo(); + return $this->hasMany(ManagedDnsRecordReference::class); + } + + public function addReference(Model $resource): ManagedDnsRecordReference + { + return $this->references()->firstOrCreate([ + 'resource_type' => $resource->getMorphClass(), + 'resource_id' => $resource->getKey(), + ]); } } diff --git a/app/Models/ManagedDnsRecordReference.php b/app/Models/ManagedDnsRecordReference.php new file mode 100644 index 0000000000..4bd97fd165 --- /dev/null +++ b/app/Models/ManagedDnsRecordReference.php @@ -0,0 +1,25 @@ +belongsTo(ManagedDnsRecord::class, 'managed_dns_record_id'); + } + + public function resource(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 768e111c28..4f081b1a88 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1947,6 +1947,21 @@ $siteAddress { $configRepository->disableSshMux(); } + /** + * Return the server's CA certificate, generating it first when it does not exist yet. + */ + public function ensureCaCertificate(): ?SslCertificate + { + $caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first(); + if ($caCertificate) { + return $caCertificate; + } + + $this->generateCaCertificate(); + + return $this->sslCertificates()->where('is_ca_certificate', true)->first(); + } + public function generateCaCertificate() { try { diff --git a/app/Models/Service.php b/app/Models/Service.php index 6ed5e836f2..4cb8647f12 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -2,11 +2,10 @@ namespace App\Models; -use App\Enums\ProcessStatus; use App\Services\ContainerStatusAggregator; use App\Support\DomainPortOverrides; +use App\Support\ResourceStartActivity; use App\Traits\Auditable; - use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; @@ -17,7 +16,6 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; -use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; #[OA\Schema( @@ -161,10 +159,7 @@ class Service extends BaseModel public function isStarting(): bool { try { - $activity = Activity::where('properties->type_uuid', $this->uuid)->latest()->first(); - $status = data_get($activity, 'properties.status'); - - return $status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value; + return ResourceStartActivity::latestRunning($this->uuid) !== null; } catch (\Throwable) { return false; } diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 6b0b4a8dea..db62005b0f 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -6,6 +6,7 @@ use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; use App\Traits\HasNoindexDomains; use App\Traits\HasRestartLimit; +use App\Traits\ReleasesManagedDnsRecords; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -13,7 +14,7 @@ use Symfony\Component\Yaml\Yaml; class ServiceApplication extends BaseModel { - use HasFactory, HasNoindexDomains, HasRestartLimit, SoftDeletes; + use HasFactory, HasNoindexDomains, HasRestartLimit, ReleasesManagedDnsRecords, SoftDeletes; protected $appends = ['url']; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f09700562f..fbdaaecab0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Once; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; @@ -33,6 +35,16 @@ class AppServiceProvider extends ServiceProvider $this->configureGitHubHttp(); $this->configureGitLabHttp(); $this->configureOidcSocialite(); + $this->configureQueue(); + } + + /** + * Queue workers are long-running processes, so once() values (e.g. instanceSettings()) + * would stay stale across jobs. Flush them before each job, like a fresh web request. + */ + private function configureQueue(): void + { + Queue::before(fn () => Once::flush()); } private function configureCommands(): void diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php index 1c6e694b7e..12537d7b52 100644 --- a/app/Services/Dns/CloudflareDnsProvider.php +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -2,6 +2,7 @@ namespace App\Services\Dns; +use App\Enums\ManagedDnsDeletionResult; use App\Exceptions\DnsRecordConflictException; use App\Models\DnsProviderZone; use App\Models\IntegrationToken; @@ -12,6 +13,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use RuntimeException; +use Throwable; class CloudflareDnsProvider { @@ -73,9 +75,11 @@ class CloudflareDnsProvider } /** - * @return array{id: string, type: string, name: string, content: string}|null + * All records of the given type for a hostname (several records form a round-robin set). + * + * @return array */ - public function findRecord(DnsProviderZone $zone, string $hostname, string $type): ?array + public function findRecords(DnsProviderZone $zone, string $hostname, string $type): array { $hostname = strtolower(rtrim($hostname, '.')); $response = $this->client($zone->integrationToken)->get( @@ -85,50 +89,67 @@ class CloudflareDnsProvider if (! $response->successful()) { throw new RuntimeException('Cloudflare DNS records could not be checked.'); } - $remote = collect($response->json('result', []))->first(); - if ($remote === null) { - return null; - } - return [ - 'id' => (string) ($remote['id'] ?? ''), - 'type' => (string) ($remote['type'] ?? $type), - 'name' => strtolower((string) ($remote['name'] ?? $hostname)), - 'content' => (string) ($remote['content'] ?? ''), - ]; + return collect($response->json('result', [])) + ->filter(fn ($remote): bool => is_array($remote)) + ->map(fn (array $remote): array => $this->normalizeRemoteRecord($remote, $hostname, $type)) + ->values() + ->all(); } + /** + * Creates the record with Coolify's ownership comment, or references an existing record that already has the wanted content. + * Existing records that Coolify did not create are only referenced and never marked as owned. + */ public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null): ManagedDnsRecord { $hostname = strtolower(rtrim($hostname, '.')); - $type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A'; - $remote = $this->findRecord($zone, $hostname, $type); - if ($remote !== null) { - if ($remote['content'] === $content) { - if ($remote['id'] === '') { - throw new RuntimeException('Cloudflare DNS records could not be checked.'); - } - - $record = $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource); - $this->auditDnsRecord('created', $zone, $hostname, $resource); - - return $record; + $type = $this->recordType($content); + $remoteRecords = $this->findRecords($zone, $hostname, $type); + $matching = collect($remoteRecords)->first(fn (array $remote): bool => $remote['content'] === $content); + if ($matching !== null) { + if ($matching['id'] === '') { + throw new RuntimeException('Cloudflare DNS records could not be checked.'); } - throw new DnsRecordConflictException($remote['id'], $remote['content'], $content); + + $record = $this->trackExistingRecord($zone, $matching, $type, $hostname, $content, $resource); + $this->auditDnsRecord('adopted', $zone, $hostname, $resource); + + return $record; } + if (count($remoteRecords) > 1) { + throw new RuntimeException("Several DNS records already exist for {$hostname}. Update them in Cloudflare."); + } + if (count($remoteRecords) === 1) { + throw new DnsRecordConflictException($remoteRecords[0]['id'], $remoteRecords[0]['content'], $content); + } + + $uuid = new_public_id(); $response = $this->client($zone->integrationToken)->post("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [ 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false, + 'comment' => ManagedDnsRecord::ownershipCommentFor($uuid), ]); if (! $response->successful() || ! is_string($response->json('result.id'))) { throw new RuntimeException('Cloudflare could not create the DNS record.'); } - $record = $this->trackRecord($zone, $response->json('result.id'), $type, $hostname, $content, $resource); + $record = ManagedDnsRecord::query()->create([ + 'uuid' => $uuid, 'team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id, + 'dns_provider_zone_id' => $zone->id, 'provider_record_id' => $response->json('result.id'), + 'type' => $type, 'name' => $hostname, 'content' => $content, 'owned' => true, + ]); + if ($resource !== null) { + $record->addReference($resource); + } $this->auditDnsRecord('created', $zone, $hostname, $resource); return $record; } + /** + * Points an existing (conflicting) record at the new content after explicit user confirmation. + * Only the content changes: proxy status, TTL, comment and other settings stay as they are. + */ public function replaceRecord( DnsProviderZone $zone, string $recordId, @@ -138,8 +159,12 @@ class CloudflareDnsProvider ?string $expectedCurrent = null, ): ManagedDnsRecord { $hostname = strtolower(rtrim($hostname, '.')); - $type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A'; - $remote = $this->findRecord($zone, $hostname, $type); + $type = $this->recordType($content); + $remoteRecords = $this->findRecords($zone, $hostname, $type); + if (count($remoteRecords) > 1) { + throw new RuntimeException("Several DNS records already exist for {$hostname}. Update them in Cloudflare."); + } + $remote = $remoteRecords[0] ?? null; if ($remote === null || $remote['id'] === '' || $remote['id'] !== $recordId @@ -148,47 +173,117 @@ class CloudflareDnsProvider throw new RuntimeException('The DNS conflict is no longer available. Check the record again.'); } - $response = $this->client($zone->integrationToken)->put( + $response = $this->client($zone->integrationToken)->patch( "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records/{$remote['id']}", - ['type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false], + ['content' => $content], ); if (! $response->successful()) { throw new RuntimeException('Cloudflare could not replace the conflicting DNS record.'); } - $record = $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource); + $record = $this->trackExistingRecord($zone, $remote, $type, $hostname, $content, $resource); $this->auditDnsRecord('replaced', $zone, $hostname, $resource); return $record; } - public function deleteRecord(ManagedDnsRecord $record): bool + /** + * Deletes the provider record only when Coolify created it and it still carries Coolify's ownership comment and value. + * Removes the local row when the provider record is gone. + */ + public function deleteRecord(ManagedDnsRecord $record, ?Model $resource = null): ManagedDnsDeletionResult { + if (! $record->owned) { + return ManagedDnsDeletionResult::NotOwned; + } + $record->loadMissing(['zone', 'integrationToken']); $url = "https://api.cloudflare.com/client/v4/zones/{$record->zone->provider_zone_id}/dns_records/{$record->provider_record_id}"; - $response = $this->client($record->integrationToken)->get($url); - $remote = $response->json('result'); - if (! $response->successful() || ($remote['type'] ?? null) !== $record->type - || strtolower((string) ($remote['name'] ?? '')) !== $record->name || ($remote['content'] ?? null) !== $record->content) { - return false; - } - if (! $this->client($record->integrationToken)->delete($url)->successful()) { - return false; - } - $record->delete(); - $this->auditDnsRecord('deleted', $record->zone, $record->name, $record->resource); - return true; + try { + $response = $this->client($record->integrationToken)->get($url); + if ($response->status() === 404) { + return $this->forgetDeletedRecord($record, ManagedDnsDeletionResult::AlreadyGone, $resource); + } + $remote = $response->json('result'); + if (! $response->successful() || ! is_array($remote)) { + return ManagedDnsDeletionResult::Failed; + } + if (($remote['type'] ?? null) !== $record->type + || strtolower((string) ($remote['name'] ?? '')) !== $record->name + || ($remote['content'] ?? null) !== $record->content + || ($remote['comment'] ?? null) !== $record->ownershipComment()) { + return ManagedDnsDeletionResult::ChangedExternally; + } + + $deleteResponse = $this->client($record->integrationToken)->delete($url); + if ($deleteResponse->status() === 404) { + return $this->forgetDeletedRecord($record, ManagedDnsDeletionResult::AlreadyGone, $resource); + } + if (! $deleteResponse->successful()) { + return ManagedDnsDeletionResult::Failed; + } + } catch (Throwable) { + return ManagedDnsDeletionResult::Failed; + } + + return $this->forgetDeletedRecord($record, ManagedDnsDeletionResult::Deleted, $resource); } - private function trackRecord(DnsProviderZone $zone, string $recordId, string $type, string $name, string $content, ?Model $resource): ManagedDnsRecord + private function forgetDeletedRecord(ManagedDnsRecord $record, ManagedDnsDeletionResult $result, ?Model $resource): ManagedDnsDeletionResult { - return ManagedDnsRecord::query()->updateOrCreate( - ['dns_provider_zone_id' => $zone->id, 'provider_record_id' => $recordId], - ['team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id, - 'resource_type' => $resource?->getMorphClass(), 'resource_id' => $resource?->getKey(), - 'type' => $type, 'name' => $name, 'content' => $content], - ); + $record->delete(); + if ($result === ManagedDnsDeletionResult::Deleted) { + $this->auditDnsRecord('deleted', $record->zone, $record->name, $resource); + } + + return $result; + } + + /** + * Tracks a record that already exists in the provider. New rows are never owned; an owned row whose + * provider comment no longer matches loses its ownership. + * + * @param array{id: string, comment: string|null} $remote + */ + private function trackExistingRecord(DnsProviderZone $zone, array $remote, string $type, string $name, string $content, ?Model $resource): ManagedDnsRecord + { + $record = ManagedDnsRecord::query()->firstOrNew(['dns_provider_zone_id' => $zone->id, 'provider_record_id' => $remote['id']]); + $record->fill([ + 'team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id, + 'type' => $type, 'name' => $name, 'content' => $content, + ]); + if (! $record->exists || $remote['comment'] !== $record->ownershipComment()) { + $record->owned = false; + } + $record->save(); + if ($resource !== null) { + $record->addReference($resource); + } + + return $record; + } + + /** + * @param array $remote + * @return array{id: string, type: string, name: string, content: string, proxied: bool|null, ttl: int|null, comment: string|null} + */ + private function normalizeRemoteRecord(array $remote, string $hostname, string $type): array + { + return [ + 'id' => (string) ($remote['id'] ?? ''), + 'type' => (string) ($remote['type'] ?? $type), + 'name' => strtolower((string) ($remote['name'] ?? $hostname)), + 'content' => (string) ($remote['content'] ?? ''), + 'proxied' => isset($remote['proxied']) ? (bool) $remote['proxied'] : null, + 'ttl' => isset($remote['ttl']) ? (int) $remote['ttl'] : null, + 'comment' => isset($remote['comment']) && is_string($remote['comment']) ? $remote['comment'] : null, + ]; + } + + private function recordType(string $content): string + { + return filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A'; } private function auditDnsRecord(string $action, DnsProviderZone $zone, string $hostname, ?Model $resource): void diff --git a/app/Services/Dns/ManagedDnsRecordCleanup.php b/app/Services/Dns/ManagedDnsRecordCleanup.php new file mode 100644 index 0000000000..32f112786b --- /dev/null +++ b/app/Services/Dns/ManagedDnsRecordCleanup.php @@ -0,0 +1,271 @@ +normalizeHostname($hostname); + $current = $resource->fresh() ?? $resource; + if (in_array($hostname, $this->hostnamesOf($current), true)) { + return null; + } + + $keys = [[$resource->getMorphClass(), $resource->getKey()]]; + $records = ManagedDnsRecord::query() + ->where('team_id', $teamId) + ->where('name', $hostname) + ->whereHas('references', fn (Builder $query) => $this->whereResourceKeys($query, $keys)) + ->get(); + + $worst = null; + foreach ($records as $record) { + $result = $this->release($record, $keys, $deleteRecord, $resource); + if ($result === ManagedDnsDeletionResult::ChangedExternally || $result === ManagedDnsDeletionResult::Failed) { + $worst = $worst === ManagedDnsDeletionResult::Failed ? $worst : $result; + } + } + + return $worst; + } + + /** + * Releases every reference of a deleted resource (and, for applications, of their previews). + * Never throws; returns false when a provider call failed and the release should be retried. + */ + public function releaseResource(string $resourceType, int|string $resourceId): bool + { + $keys = $this->resourceKeys($resourceType, $resourceId); + $records = ManagedDnsRecord::query() + ->whereHas('references', fn (Builder $query) => $this->whereResourceKeys($query, $keys)) + ->get(); + + $successful = true; + foreach ($records as $record) { + try { + if ($this->release($record, $keys, true) === ManagedDnsDeletionResult::Failed) { + $successful = false; + } + } catch (Throwable $e) { + $successful = false; + Log::warning('Managed DNS record cleanup failed after resource deletion.', [ + 'managed_dns_record_id' => $record->id, + 'hostname' => $record->name, + 'error' => $e->getMessage(), + ]); + } + } + + return $successful; + } + + /** + * @param array $releasingKeys + */ + public function release(ManagedDnsRecord $record, array $releasingKeys, bool $deleteRecord, ?Model $contextResource = null): ?ManagedDnsDeletionResult + { + $releasing = collect($releasingKeys)->map(fn (array $key): string => $key[0].'|'.$key[1]); + $otherReferences = $record->references()->get() + ->reject(fn (ManagedDnsRecordReference $reference): bool => $releasing->contains($reference->resource_type.'|'.$reference->resource_id)); + + $liveReferences = $otherReferences->filter(function (ManagedDnsRecordReference $reference): bool { + if ($reference->resource !== null) { + return true; + } + $reference->delete(); + + return false; + }); + if ($liveReferences->isNotEmpty()) { + $this->deleteReferences($record, $releasingKeys); + + return null; + } + + $users = $this->resourcesUsingHostname($record->name, $releasing->all()); + if ($users->isNotEmpty()) { + $sameTeamUsers = $users->filter(fn (Model $user): bool => $this->teamIdOf($user) === (int) $record->team_id); + if ($sameTeamUsers->isEmpty()) { + $this->forget($record, 'hostname_used_by_another_team'); + + return null; + } + $sameTeamUsers->each(fn (Model $user) => $record->addReference($user)); + $this->deleteReferences($record, $releasingKeys); + + return null; + } + + if (! $record->owned || ! $deleteRecord) { + $record->delete(); + + return $record->owned ? null : ManagedDnsDeletionResult::NotOwned; + } + + $result = $this->provider->deleteRecord($record, $contextResource); + if ($result === ManagedDnsDeletionResult::ChangedExternally) { + $this->forget($record, 'remote_record_changed'); + } elseif ($result === ManagedDnsDeletionResult::Failed) { + $this->auditSkipped($record, 'provider_unavailable'); + } + + return $result; + } + + /** + * Live applications, previews and service applications (any team) that use the hostname, except the given resource keys. + * + * @param array $exceptKeys "morph-class|id" keys + * @return Collection + */ + public function resourcesUsingHostname(string $hostname, array $exceptKeys = []): Collection + { + $hostname = $this->normalizeHostname($hostname); + $pattern = '%'.$hostname.'%'; + $matchesFqdnOrCompose = fn (Builder $query) => $query->where(fn (Builder $query) => $query + ->whereRaw('LOWER(fqdn) LIKE ?', [$pattern]) + ->orWhereRaw('LOWER(docker_compose_domains) LIKE ?', [$pattern])); + + return collect() + ->concat(Application::query()->where($matchesFqdnOrCompose)->get()) + ->concat(ApplicationPreview::query()->where($matchesFqdnOrCompose)->get()) + ->concat(ServiceApplication::query()->whereRaw('LOWER(fqdn) LIKE ?', [$pattern])->get()) + ->reject(fn (Model $resource): bool => in_array($resource->getMorphClass().'|'.$resource->getKey(), $exceptKeys, true)) + ->filter(fn (Model $resource): bool => in_array($hostname, $this->hostnamesOf($resource), true)) + ->values(); + } + + /** + * @return array + */ + public function hostnamesOf(Model $resource): array + { + $attributes = $resource->getAttributes(); + $values = $this->splitUrls($attributes['fqdn'] ?? null); + + $composeDomains = json_decode((string) ($attributes['docker_compose_domains'] ?? ''), true); + if (is_array($composeDomains)) { + foreach ($composeDomains as $service) { + $domain = is_array($service) ? ($service['domain'] ?? null) : null; + array_push($values, ...$this->splitUrls(is_string($domain) ? $domain : null)); + } + } + + return collect($values) + ->map(fn (string $url) => parse_url(str_contains($url, '://') ? $url : 'http://'.$url, PHP_URL_HOST)) + ->filter(fn ($host): bool => is_string($host) && $host !== '') + ->map(fn (string $host): string => $this->normalizeHostname($host)) + ->unique() + ->values() + ->all(); + } + + private function teamIdOf(Model $resource): ?int + { + $teamId = match (true) { + $resource instanceof ApplicationPreview => data_get($resource, 'application.environment.project.team_id'), + $resource instanceof ServiceApplication => data_get($resource, 'service.environment.project.team_id'), + default => data_get($resource, 'environment.project.team_id'), + }; + + return $teamId === null ? null : (int) $teamId; + } + + /** + * @return array + */ + private function resourceKeys(string $resourceType, int|string $resourceId): array + { + $keys = [[$resourceType, $resourceId]]; + if ($resourceType === (new Application)->getMorphClass()) { + $previewType = (new ApplicationPreview)->getMorphClass(); + ApplicationPreview::withTrashed()->where('application_id', $resourceId)->pluck('id') + ->each(function ($previewId) use (&$keys, $previewType): void { + $keys[] = [$previewType, $previewId]; + }); + } + + return $keys; + } + + /** + * @param array $keys + */ + private function whereResourceKeys(Builder $query, array $keys): Builder + { + return $query->where(function (Builder $query) use ($keys): void { + foreach ($keys as [$type, $id]) { + $query->orWhere(fn (Builder $query) => $query->where('resource_type', $type)->where('resource_id', $id)); + } + }); + } + + /** + * @param array $keys + */ + private function deleteReferences(ManagedDnsRecord $record, array $keys): void + { + $this->whereResourceKeys($record->references()->getQuery(), $keys)->delete(); + } + + /** + * Stops tracking the record without touching the provider. + */ + private function forget(ManagedDnsRecord $record, string $reason): void + { + $record->delete(); + $this->auditSkipped($record, $reason); + } + + private function auditSkipped(ManagedDnsRecord $record, string $reason): void + { + $source = auth()->check() ? 'ui' : 'system'; + auditLog("{$source}.dns_record.delete_skipped", [ + 'team_id' => $record->team_id, + 'hostname' => $record->name, + 'provider' => 'cloudflare', + 'reason' => $reason, + ], 'warning'); + } + + /** + * @return array + */ + private function splitUrls(?string $value): array + { + if (blank($value)) { + return []; + } + + return collect(explode(',', $value))->map(fn ($url) => trim((string) $url))->filter()->values()->all(); + } + + private function normalizeHostname(string $hostname): string + { + return strtolower(rtrim($hostname, '.')); + } +} diff --git a/app/Services/TraefikAcmeService.php b/app/Services/TraefikAcmeService.php new file mode 100644 index 0000000000..577fe9ae8c --- /dev/null +++ b/app/Services/TraefikAcmeService.php @@ -0,0 +1,127 @@ +, store: ?string, expires_at: ?string}> + */ + public function certificates(string $contents): array + { + $data = $this->decode($contents); + $certificates = []; + + foreach ($data as $resolver => $resolverData) { + if (! is_string($resolver) || ! is_array($resolverData)) { + continue; + } + + $resolverCertificates = data_get($resolverData, 'Certificates', []); + if (! is_array($resolverCertificates)) { + continue; + } + + foreach ($resolverCertificates as $certificate) { + if (! is_array($certificate)) { + continue; + } + + $mainDomain = data_get($certificate, 'domain.main'); + if (! is_string($mainDomain) || $mainDomain === '') { + continue; + } + + $certificateSans = data_get($certificate, 'domain.sans', []); + $sans = array_values(array_filter( + is_array($certificateSans) ? $certificateSans : [], + fn (mixed $domain): bool => is_string($domain) && $domain !== '', + )); + + $certificates[] = [ + 'id' => $this->certificateId($resolver, $certificate), + 'resolver' => $resolver, + 'main_domain' => $mainDomain, + 'sans' => $sans, + 'store' => is_string(data_get($certificate, 'Store')) ? data_get($certificate, 'Store') : null, + 'expires_at' => $this->expirationDate(data_get($certificate, 'certificate')), + ]; + } + } + + return $certificates; + } + + public function deleteCertificate(string $contents, string $resolver, string $certificateId): string + { + $data = $this->decode($contents); + $certificates = $data[$resolver]['Certificates'] ?? null; + + if (! is_array($certificates)) { + throw new RuntimeException('The selected certificate could not be found.'); + } + + $remaining = []; + $deleted = false; + + foreach ($certificates as $certificate) { + if (! $deleted && is_array($certificate) && hash_equals($this->certificateId($resolver, $certificate), $certificateId)) { + $deleted = true; + + continue; + } + + $remaining[] = $certificate; + } + + if (! $deleted) { + throw new RuntimeException('The selected certificate could not be found.'); + } + + $data[$resolver]['Certificates'] = $remaining; + + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL; + } + + /** @return array */ + private function decode(string $contents): array + { + try { + $data = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new RuntimeException('The Traefik ACME file contains invalid JSON.', previous: $exception); + } + + if (! is_array($data)) { + throw new RuntimeException('The Traefik ACME file contains invalid JSON.'); + } + + return $data; + } + + /** @param array $certificate */ + private function certificateId(string $resolver, array $certificate): string + { + return hash('sha256', $resolver."\0".json_encode($certificate, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); + } + + private function expirationDate(mixed $encodedCertificate): ?string + { + if (! is_string($encodedCertificate) || $encodedCertificate === '') { + return null; + } + + $der = base64_decode($encodedCertificate, true); + if ($der === false) { + return null; + } + + $pem = "-----BEGIN CERTIFICATE-----\n".chunk_split(base64_encode($der), 64, "\n")."-----END CERTIFICATE-----\n"; + $details = openssl_x509_parse($pem); + $expiresAt = data_get($details, 'validTo_time_t'); + + return is_int($expiresAt) ? gmdate('Y-m-d H:i:s', $expiresAt) : null; + } +} diff --git a/app/Support/ResourceStartActivity.php b/app/Support/ResourceStartActivity.php new file mode 100644 index 0000000000..e4baaec978 --- /dev/null +++ b/app/Support/ResourceStartActivity.php @@ -0,0 +1,160 @@ +value, + ProcessStatus::IN_PROGRESS->value, + ]; + + /** + * The latest activity for a resource, if it is queued or in progress and not stale. + */ + public static function latestRunning(string $typeUuid): ?Activity + { + $activity = Activity::query() + ->where('properties->type_uuid', $typeUuid) + ->latest() + ->first(); + + return self::isRunning($activity) ? $activity : null; + } + + public static function isRunning(?Activity $activity): bool + { + if (! $activity || ! in_array(data_get($activity, 'properties.status'), self::ACTIVE_STATUSES, true)) { + return false; + } + + return ! self::isStale($activity); + } + + public static function isStale(Activity $activity): bool + { + $lastProgressAt = $activity->updated_at ?? $activity->created_at; + if (! $lastProgressAt) { + return true; + } + + $staleAfterSeconds = data_get($activity, 'properties.status') === ProcessStatus::QUEUED->value + ? self::QUEUED_STALE_AFTER_SECONDS + : self::IN_PROGRESS_STALE_AFTER_SECONDS; + + return $lastProgressAt->lte(now()->subSeconds($staleAfterSeconds)); + } + + /** + * Mark an activity as failed, append the reason to its log and stop log polling. + */ + public static function markFailed(Activity $activity, string $message): void + { + $properties = [ + 'status' => ProcessStatus::ERROR->value, + 'error' => $message, + 'failed_at' => now()->toIso8601String(), + ]; + if ($activity->properties->get('exitCode') === null) { + $properties['exitCode'] = 1; + } + + $activity->properties = $activity->properties->merge($properties); + $activity->description = self::appendOutput($activity->description, $message); + $activity->save(); + } + + /** + * Fail database and service start activities left queued or in progress by a restart. + * Runs during app:init, before any queue worker is started. + */ + public static function failInterrupted(): int + { + $activities = Activity::query() + ->whereIn('properties->status', self::ACTIVE_STATUSES) + ->get(); + + $serviceUuids = self::existingServiceUuids($activities); + + $interrupted = $activities->filter( + fn (Activity $activity): bool => data_get($activity, 'properties.operation') === self::DATABASE_START_OPERATION + || $serviceUuids->contains(data_get($activity, 'properties.type_uuid')) + ); + + $interrupted->each(fn (Activity $activity) => self::markFailed($activity, self::INTERRUPTED_MESSAGE)); + + return $interrupted->count(); + } + + /** + * @param Collection $activities + * @return Collection + */ + private static function existingServiceUuids(Collection $activities): Collection + { + $typeUuids = $activities + ->map(fn (Activity $activity) => data_get($activity, 'properties.type_uuid')) + ->filter(fn ($uuid): bool => is_string($uuid) && $uuid !== '') + ->unique() + ->values(); + + if ($typeUuids->isEmpty()) { + return collect(); + } + + return $typeUuids + ->chunk(500) + ->flatMap(fn (Collection $chunk) => Service::query()->whereIn('uuid', $chunk->all())->pluck('uuid')) + ->values(); + } + + private static function appendOutput(?string $description, string $message): string + { + try { + $entries = json_decode($description ?: '[]', true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $entries = []; + } + if (! is_array($entries)) { + $entries = []; + } + + $lastOrder = collect($entries)->max('order') ?? 0; + $entries[] = [ + 'type' => 'stderr', + 'output' => "\n{$message}\n", + 'timestamp' => hrtime(true), + 'batch' => 1, + 'order' => $lastOrder + 1, + ]; + + return json_encode($entries, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } +} diff --git a/app/Traits/ReleasesManagedDnsRecords.php b/app/Traits/ReleasesManagedDnsRecords.php new file mode 100644 index 0000000000..b519d123a4 --- /dev/null +++ b/app/Traits/ReleasesManagedDnsRecords.php @@ -0,0 +1,52 @@ +getMorphClass(), $resource->getKey())->afterCommit(); + } catch (Throwable $e) { + Log::warning('Could not queue managed DNS cleanup for a deleted resource.', [ + 'resource_type' => $resource->getMorphClass(), + 'resource_id' => $resource->getKey(), + 'error' => $e->getMessage(), + ]); + } + }); + } + + private static function hasManagedDnsReferences(Model $resource): bool + { + $query = ManagedDnsRecordReference::query()->where(fn ($query) => $query + ->where('resource_type', $resource->getMorphClass()) + ->where('resource_id', $resource->getKey())); + + if ($resource instanceof Application) { + $query->orWhere(fn ($query) => $query + ->where('resource_type', (new ApplicationPreview)->getMorphClass()) + ->whereIn('resource_id', ApplicationPreview::withTrashed()->where('application_id', $resource->getKey())->select('id'))); + } + + return $query->exists(); + } +} diff --git a/database/factories/ManagedDnsRecordFactory.php b/database/factories/ManagedDnsRecordFactory.php index e4c163036d..c35e516ba9 100644 --- a/database/factories/ManagedDnsRecordFactory.php +++ b/database/factories/ManagedDnsRecordFactory.php @@ -17,6 +17,15 @@ class ManagedDnsRecordFactory extends Factory 'integration_token_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integration_token_id, 'team_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integrationToken->team_id, 'provider_record_id' => fake()->uuid(), 'type' => 'A', 'name' => fake()->domainName(), 'content' => fake()->ipv4(), + 'owned' => false, ]; } + + /** + * A record Coolify created itself (carries the ownership comment in the provider). + */ + public function owned(): static + { + return $this->state(fn (array $attributes) => ['owned' => true]); + } } diff --git a/database/migrations/2026_09_25_154121_add_owned_to_managed_dns_records_table.php b/database/migrations/2026_09_25_154121_add_owned_to_managed_dns_records_table.php new file mode 100644 index 0000000000..c5eed28e4f --- /dev/null +++ b/database/migrations/2026_09_25_154121_add_owned_to_managed_dns_records_table.php @@ -0,0 +1,27 @@ +boolean('owned')->default(false)->after('content'); + }); + } + + public function down(): void + { + Schema::table('managed_dns_records', function (Blueprint $table) { + $table->dropColumn('owned'); + }); + } +}; diff --git a/database/migrations/2026_09_25_154122_create_managed_dns_record_references_table.php b/database/migrations/2026_09_25_154122_create_managed_dns_record_references_table.php new file mode 100644 index 0000000000..7b4a9075c2 --- /dev/null +++ b/database/migrations/2026_09_25_154122_create_managed_dns_record_references_table.php @@ -0,0 +1,56 @@ +id(); + $table->foreignId('managed_dns_record_id')->constrained()->cascadeOnDelete(); + $table->morphs('resource'); + $table->timestamps(); + $table->unique(['managed_dns_record_id', 'resource_type', 'resource_id'], 'managed_dns_record_references_unique'); + }); + + DB::table('managed_dns_records') + ->whereNotNull('resource_type') + ->whereNotNull('resource_id') + ->orderBy('id') + ->chunkById(500, function ($records): void { + DB::table('managed_dns_record_references')->insertOrIgnore($records->map(fn ($record): array => [ + 'managed_dns_record_id' => $record->id, + 'resource_type' => $record->resource_type, + 'resource_id' => $record->resource_id, + 'created_at' => now(), + 'updated_at' => now(), + ])->all()); + }); + + Schema::table('managed_dns_records', function (Blueprint $table) { + $table->dropMorphs('resource'); + }); + } + + public function down(): void + { + Schema::table('managed_dns_records', function (Blueprint $table) { + $table->nullableMorphs('resource'); + }); + + DB::table('managed_dns_record_references')->orderBy('id')->chunkById(500, function ($references): void { + foreach ($references as $reference) { + DB::table('managed_dns_records') + ->where('id', $reference->managed_dns_record_id) + ->whereNull('resource_type') + ->update(['resource_type' => $reference->resource_type, 'resource_id' => $reference->resource_id]); + } + }); + + Schema::dropIfExists('managed_dns_record_references'); + } +}; diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 7d5f868dc8..a0758e9228 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -26,16 +26,221 @@ CURRENT_USER=$USER if [ $EUID != 0 ]; then echo "Please run this script as root or with sudo" - exit + exit 1 fi -echo "" +mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} +mkdir -p /data/coolify/ssh/{keys,mux} +mkdir -p /data/coolify/proxy/dynamic + +chown -R 9999:root /data/coolify +chmod -R 700 /data/coolify + +INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log" + +# Terminal UI: the terminal only shows the step list, everything else goes to the log file +TOTAL_STEPS=9 +UI_STEP=0 +UI_STEP_TITLE="" +UI_STEP_DETAIL="" +UI_STEP_STARTED=0 +UI_SPINNER_PID="" +UI_WARNINGS=() + +if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ]; then + UI_TTY=true +else + UI_TTY=false +fi + +if [ "$UI_TTY" = true ] && [ -z "${NO_COLOR:-}" ]; then + C_RESET=$'\033[0m' + C_BOLD=$'\033[1m' + C_DIM=$'\033[2m' + C_PURPLE=$'\033[38;5;135m' + C_GREEN=$'\033[32m' + C_RED=$'\033[31m' + C_YELLOW=$'\033[33m' +else + C_RESET="" C_BOLD="" C_DIM="" C_PURPLE="" C_GREEN="" C_RED="" C_YELLOW="" +fi + +UI_WIDTH=$(tput cols 2>/dev/null || true) +if ! [[ $UI_WIDTH =~ ^[0-9]+$ ]]; then + UI_WIDTH=80 +fi +UI_WIDTH=$((UI_WIDTH > 100 ? 100 : UI_WIDTH < 60 ? 60 : UI_WIDTH)) + +# Current time in milliseconds +ui_now() { + if [ -n "${EPOCHREALTIME:-}" ]; then + local now=${EPOCHREALTIME/[.,]/} + echo $((now / 1000)) + else + echo $(($(date +%s) * 1000)) + fi +} + +ui_duration() { + local ms=$1 + if [ "$ms" -lt 60000 ]; then + printf '%d.%ds' $((ms / 1000)) $((ms % 1000 / 100)) + else + printf '%dm %ds' $((ms / 60000)) $((ms % 60000 / 1000)) + fi +} + +ui_repeat() { + local out="" i + for ((i = 0; i < $2; i++)); do + out+="$1" + done + printf '%s' "$out" +} + +# Prints the current step line without the trailing status symbol +ui_render_step() { + local right="$1" + local title="$UI_STEP_TITLE" + local detail="$UI_STEP_DETAIL" + local plain_detail="${detail//·/.}" + local left=$((7 + ${#title})) + local right_width=$((${#right} + 2)) + + if [ -n "$detail" ] && [ $((left + 2 + ${#plain_detail} + right_width + 2)) -le "$UI_WIDTH" ]; then + left=$((left + 2 + ${#plain_detail})) + else + detail="" + fi + + local pad=$((UI_WIDTH - left - right_width)) + if [ "$pad" -lt 2 ]; then + pad=2 + fi + printf ' %s%-5s%s%s%s%s' "$C_DIM" "${UI_STEP}/${TOTAL_STEPS}" "$C_RESET" "$C_BOLD" "$title" "$C_RESET" + if [ -n "$detail" ]; then + printf ' %s%s%s' "$C_DIM" "$detail" "$C_RESET" + fi + printf '%*s%s%s%s ' "$pad" "" "$C_DIM" "$right" "$C_RESET" +} + +ui_bar() { + local percent=$1 label=$2 + local cells=$((UI_WIDTH - 30 > 40 ? 40 : UI_WIDTH - 30)) + local filled=$((percent * cells / 100)) + printf ' %s%s%s%s%s %s%3d%%%s %s%s%s' \ + "$C_PURPLE" "$(ui_repeat ■ "$filled")" "$C_DIM" "$(ui_repeat ■ $((cells - filled)))" "$C_RESET" \ + "$C_BOLD" "$percent" "$C_RESET" "$C_DIM" "$label" "$C_RESET" +} + +# Runs in the background and animates the current step line and the progress bar below it +ui_spinner() { + set +x + local frames=(◜ ◠ ◝ ◞ ◡ ◟) i=0 stop=false + local line bar + trap 'stop=true' TERM + line=$(ui_render_step "") + bar=$(ui_bar $(((UI_STEP - 1) * 100 / TOTAL_STEPS)) "${UI_STEP_TITLE,,}…") + while [ "$stop" = false ]; do + printf '\r\033[2K%s%s%s%s\n\033[2K%s\033[1A\r' "$line" "$C_PURPLE" "${frames[i]}" "$C_RESET" "$bar" >&3 + i=$(((i + 1) % ${#frames[@]})) + sleep 0.12 + done +} + +ui_stop_spinner() { + if [ -n "$UI_SPINNER_PID" ]; then + kill "$UI_SPINNER_PID" 2>/dev/null || true + wait "$UI_SPINNER_PID" 2>/dev/null || true + UI_SPINNER_PID="" + fi +} + +# Shows a warning below the current step (or right away when no step is running) +warn() { + echo "WARNING: $*" + UI_WARNINGS+=("$*") +} + +ui_flush_warnings() { + local warning + for warning in "${UI_WARNINGS[@]}"; do + printf ' %s! %s%s\n' "$C_YELLOW" "$warning" "$C_RESET" >&3 + done + UI_WARNINGS=() +} + +step_start() { + UI_STEP=$((UI_STEP + 1)) + UI_STEP_TITLE="$1" + UI_STEP_DETAIL="${2:-}" + UI_STEP_STARTED=$(ui_now) + log_section "Step ${UI_STEP}/${TOTAL_STEPS}: $1" + if [ "$UI_TTY" = true ]; then + ui_spinner & + UI_SPINNER_PID=$! + fi +} + +# Usage: step_done [detail] - optionally replaces the detail text shown next to the step title +step_done() { + if [ -n "${1:-}" ]; then + UI_STEP_DETAIL="$1" + fi + ui_stop_spinner + local elapsed + elapsed=$(ui_duration $(($(ui_now) - UI_STEP_STARTED))) + log "Step ${UI_STEP}/${TOTAL_STEPS} completed in ${elapsed}" + if [ "$UI_TTY" = true ]; then + printf '\r\033[2K' >&3 + fi + printf '%s%s✓%s\n' "$(ui_render_step "$elapsed")" "$C_GREEN" "$C_RESET" >&3 + if [ "$UI_TTY" = true ]; then + printf '\033[2K' >&3 + fi + ui_flush_warnings + UI_STEP_TITLE="" +} + +ui_on_exit() { + local code=$? + ui_stop_spinner + if [ "$code" -ne 0 ]; then + if [ "$UI_TTY" = true ]; then + printf '\r\033[2K' >&3 + fi + if [ -n "$UI_STEP_TITLE" ]; then + printf '%s%s✗%s\n' "$(ui_render_step "$(ui_duration $(($(ui_now) - UI_STEP_STARTED)))")" "$C_RED" "$C_RESET" >&3 + fi + if [ "$UI_TTY" = true ]; then + printf '\033[2K' >&3 + fi + ui_flush_warnings + printf '\n %s%sInstallation failed.%s Last lines of the log:\n\n' "$C_BOLD" "$C_RED" "$C_RESET" >&3 + tail -n 15 "$INSTALLATION_LOG_WITH_DATE" 2>/dev/null | sed 's/^/ /' >&3 + printf '\n %sFull log: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3 + fi + if [ "$UI_TTY" = true ]; then + printf '\033[?25h' >&3 + fi +} + +# fd 3 is the terminal, stdout and stderr go to the log file +exec 3>&1 +exec >>"$INSTALLATION_LOG_WITH_DATE" 2>&1 +trap ui_on_exit EXIT + +if [ "$UI_TTY" = true ]; then + printf '\033[?25l' >&3 +fi +printf '\n %sWelcome to Coolify Installer!%s\n' "$C_BOLD" "$C_RESET" >&3 +printf ' %sThis script will install everything for you. Sit back and relax.%s\n' "$C_DIM" "$C_RESET" >&3 +printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3 +UI_INSTALL_STARTED=$(ui_now) + echo "==========================================" echo " Coolify Installation - ${DATE}" echo "==========================================" -echo "" -echo "Welcome to Coolify Installer!" -echo "This script will install everything for you. Sit back and relax." echo "Source code: https://github.com/coollabsio/coolify/blob/v4.x/scripts/install.sh" # Predefined root user @@ -157,9 +362,7 @@ if [ -f /etc/docker/daemon.json ]; then if [ "$DOCKER_POOL_FORCE_OVERRIDE" = true ]; then echo "Force override enabled - network pool will be updated with $DOCKER_ADDRESS_POOL_BASE/$DOCKER_ADDRESS_POOL_SIZE." else - echo "Custom pool provided but force override not enabled - using existing configuration." - echo "To force override, set DOCKER_POOL_FORCE_OVERRIDE=true" - echo "This won't change the existing docker networks, only the pool configuration for the newly created networks." + warn "Custom Docker network pool ignored, using the existing $EXISTING_POOL_BASE/$EXISTING_POOL_SIZE. Set DOCKER_POOL_FORCE_OVERRIDE=true to override it (existing networks are not changed)." DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE" DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE" DOCKER_POOL_BASE_PROVIDED=false @@ -172,7 +375,7 @@ fi # Validate Docker address pool configuration if ! [[ $DOCKER_ADDRESS_POOL_BASE =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then - echo "Warning: Invalid network pool base format: $DOCKER_ADDRESS_POOL_BASE" + warn "Invalid Docker network pool base: $DOCKER_ADDRESS_POOL_BASE" if [ "$EXISTING_POOL_CONFIGURED" = true ]; then echo "Using existing configuration: $EXISTING_POOL_BASE" DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE" @@ -183,7 +386,7 @@ if ! [[ $DOCKER_ADDRESS_POOL_BASE =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; fi if ! [[ $DOCKER_ADDRESS_POOL_SIZE =~ ^[0-9]+$ ]] || [ "$DOCKER_ADDRESS_POOL_SIZE" -lt 16 ] || [ "$DOCKER_ADDRESS_POOL_SIZE" -gt 28 ]; then - echo "Warning: Invalid network pool size: $DOCKER_ADDRESS_POOL_SIZE (must be 16-28)" + warn "Invalid Docker network pool size: $DOCKER_ADDRESS_POOL_SIZE (must be 16-28)" if [ "$EXISTING_POOL_CONFIGURED" = true ]; then echo "Using existing configuration: $EXISTING_POOL_SIZE" DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE" @@ -201,52 +404,20 @@ WARNING_SPACE=false if [ "$TOTAL_SPACE" -lt "$REQUIRED_TOTAL_SPACE" ]; then WARNING_SPACE=true - cat < >(tee -a $INSTALLATION_LOG_WITH_DATE) 2>&1 - -getAJoke() { - JOKES=$(curl -s --max-time 2 "https://v2.jokeapi.dev/joke/Programming?blacklistFlags=nsfw,religious,political,racist,sexist,explicit&format=txt&type=single" || true) - if [ "$JOKES" != "" ]; then - echo -e " - Until then, here's a joke for you:\n" - echo -e "$JOKES\n" - fi -} - # Helper function to log with timestamp log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" @@ -329,7 +500,7 @@ case "$OS_TYPE" in arch | ubuntu | debian | raspbian | centos | fedora | rhel | ol | rocky | sles | opensuse-leap | opensuse-tumbleweed | almalinux | amzn | alpine | postmarketos | tencentos) ;; *) echo "This script only supports Debian, Redhat, Arch Linux, Alpine Linux, or SLES based operating systems for now." - exit + exit 1 ;; esac @@ -348,10 +519,9 @@ echo "| Helper | $LATEST_HELPER_VERSION" echo "| Docker Pool | $DOCKER_ADDRESS_POOL_BASE (size $DOCKER_ADDRESS_POOL_SIZE)" echo "| Registry URL | $REGISTRY_URL" echo "---------------------------------------------" -echo "" -log_section "Step 1/9: Installing required packages" -echo "1/9 Installing required packages (curl, wget, git, jq, openssl)..." +ui_flush_warnings +step_start "Installing required packages" "curl wget git jq openssl" # Track if apt-get update was run to avoid redundant calls later APT_UPDATED=false @@ -359,6 +529,7 @@ APT_UPDATED=false if all_packages_installed; then log "All required packages already installed, skipping installation" echo " - All required packages already installed." + UI_STEP_DETAIL="already installed" else case "$OS_TYPE" in arch) @@ -393,15 +564,14 @@ else ;; *) echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now." - exit + exit 1 ;; esac log "Required packages installed successfully" fi -echo " Done." +step_done -log_section "Step 2/9: Checking OpenSSH server configuration" -echo "2/9 Checking OpenSSH server configuration..." +step_start "Checking OpenSSH server configuration" # Detect OpenSSH server SSH_DETECTED=false @@ -476,8 +646,7 @@ SSH_PERMIT_ROOT_LOGIN=$(sshd -T | grep -i "permitrootlogin" | awk '{print $2}') if [ "$SSH_PERMIT_ROOT_LOGIN" = "yes" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "without-password" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "prohibit-password" ]; then echo " - SSH PermitRootLogin is enabled." else - echo " - SSH PermitRootLogin is disabled." - echo " If you have problems with SSH, please read this: https://coolify.io/docs/knowledge-base/server/openssh" + warn "SSH PermitRootLogin is disabled. If you have problems with SSH, read https://coolify.io/docs/knowledge-base/server/openssh" fi # Detect if docker is installed via snap @@ -542,11 +711,11 @@ install_docker_from_rhel_repo() { systemctl --now enable docker } -log_section "Step 3/9: Checking Docker installation" -echo "3/9 Checking Docker installation..." +step_done + if ! [ -x "$(command -v docker)" ]; then + step_start "Installing Docker Engine" echo " - Docker is not installed. Installing Docker. It may take a while." - getAJoke case "$OS_TYPE" in "alpine" | "postmarketos") apk add docker docker-cli-compose >/dev/null 2>&1 @@ -616,6 +785,7 @@ if ! [ -x "$(command -v docker)" ]; then esac echo " - Docker installed successfully." else + step_start "Checking Docker Engine" echo " - Docker is installed." fi @@ -623,7 +793,7 @@ fi MIN_DOCKER_VERSION=24 INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1) if [ -z "$INSTALLED_DOCKER_VERSION" ]; then - echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed." + warn "Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed." elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer." echo " Please upgrade Docker: https://docs.docker.com/engine/install/" @@ -631,9 +801,10 @@ elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then else echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)." fi +DOCKER_SERVER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null || true) +step_done "${DOCKER_SERVER_VERSION:+v$DOCKER_SERVER_VERSION}" -log_section "Step 4/9: Checking Docker configuration" -echo "4/9 Checking Docker configuration..." +step_start "Configuring Docker daemon" "log rotation · address pools" echo " - Network pool configuration: ${DOCKER_ADDRESS_POOL_BASE}/${DOCKER_ADDRESS_POOL_SIZE}" echo " - To override existing configuration: DOCKER_POOL_FORCE_OVERRIDE=true" @@ -762,8 +933,9 @@ else fi fi -log_section "Step 5/9: Downloading required files from CDN" -echo "5/9 Downloading required files from CDN..." +step_done + +step_start "Downloading required files from CDN" log "Downloading configuration files in parallel..." # Download files in parallel for faster installation @@ -793,10 +965,9 @@ fi chmod +x /data/coolify/source/upgrade.sh /data/coolify/source/upgrade-postgres.sh log "All configuration files downloaded successfully" -echo " Done." +step_done -log_section "Step 6/9: Setting up environment variable file" -echo "6/9 Setting up environment variable file..." +step_start "Setting up environment variables" if [ -f "$ENV_FILE" ]; then # If .env exists, create backup @@ -812,10 +983,6 @@ else cp "/data/coolify/source/.env.production" "$ENV_FILE" fi log "Environment file setup completed" -echo " Done." - -log_section "Step 7/9: Checking and updating environment variables" -echo "7/9 Checking and updating environment variables..." update_env_var() { local key="$1" @@ -877,10 +1044,10 @@ else fi fi log "Environment variables check completed" -echo " Done." +step_done -log_section "Step 8/9: Checking SSH key for localhost access" -echo "8/9 Checking SSH key for localhost access..." +step_start "Checking SSH key for localhost access" +SSH_KEY_DETAIL="existing key" if [ ! -f ~/.ssh/authorized_keys ]; then mkdir -p ~/.ssh chmod 700 ~/.ssh @@ -894,6 +1061,7 @@ set -e if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then echo " - Generating SSH key." + SSH_KEY_DETAIL="generated new key" test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify @@ -906,20 +1074,18 @@ fi chown -R 9999:root /data/coolify chmod -R 700 /data/coolify log "SSH key check completed" -echo " Done." - -log_section "Step 9/9: Installing Coolify" -echo "9/9 Installing Coolify ($LATEST_VERSION)..." -echo -e " - It could take a while based on your server's performance, network speed, stars, etc." -echo -e " - Please wait." -getAJoke +step_done "$SSH_KEY_DETAIL" +step_start "Pulling images" "coolify · postgres · redis · helper" +# fd 3 is closed so the detached container restart in upgrade.sh does not hold the terminal open if [[ $- == *x* ]]; then - bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" + bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" 3>&- else - bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" + bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" 3>&- fi -echo " - Coolify installed successfully." +step_done + +step_start "Starting Coolify" "v${LATEST_VERSION}" echo " - Waiting for Coolify to be ready..." # Wait for upgrade.sh background process to complete @@ -999,14 +1165,11 @@ if [ "$HEALTH" != "healthy" ]; then echo " - Please check: docker logs coolify" exit 1 fi -echo -e "\033[0;35m - ____ _ _ _ _ _ - / ___|___ _ __ __ _ _ __ __ _| |_ _ _| | __ _| |_(_) ___ _ __ ___| | - | | / _ \| '_ \ / _\` | '__/ _\` | __| | | | |/ _\` | __| |/ _ \| '_ \/ __| | - | |__| (_) | | | | (_| | | | (_| | |_| |_| | | (_| | |_| | (_) | | | \__ \_| - \____\___/|_| |_|\__, |_| \__,_|\__|\__,_|_|\__,_|\__|_|\___/|_| |_|___(_) - |___/ -\033[0m" +step_done + +if [ "$UI_TTY" = true ]; then + printf '%s\n' "$(ui_bar 100 "done")" >&3 +fi # Fetch public IPs in parallel for faster completion IPV4_TMP=$(mktemp) @@ -1021,29 +1184,29 @@ IPV4_PUBLIC_IP=$(cat "$IPV4_TMP" 2>/dev/null || true) IPV6_PUBLIC_IP=$(cat "$IPV6_TMP" 2>/dev/null || true) rm -f "$IPV4_TMP" "$IPV6_TMP" -echo -e "\nYour instance is ready to use!\n" -if [ -n "$IPV4_PUBLIC_IP" ]; then - echo -e "You can access Coolify through your Public IPV4: http://$IPV4_PUBLIC_IP:8000" -fi -if [ -n "$IPV6_PUBLIC_IP" ]; then - echo -e "You can access Coolify through your Public IPv6: http://[$IPV6_PUBLIC_IP]:8000" -fi - set +e DEFAULT_PRIVATE_IP=$(ip route get 1 | sed -n 's/^.*src \([0-9.]*\) .*$/\1/p') PRIVATE_IPS=$(hostname -I 2>/dev/null || ip -o addr show scope global | awk '{print $4}' | cut -d/ -f1) set -e -if [ -n "$PRIVATE_IPS" ]; then - echo -e "\nIf your Public IP is not accessible, you can use the following Private IPs:\n" - for IP in $PRIVATE_IPS; do - if [ "$IP" != "$DEFAULT_PRIVATE_IP" ]; then - echo -e "http://$IP:8000" - fi - done +printf '\n %s%sCoolify is ready!%s %sInstalled in %s%s\n\n' "$C_BOLD" "$C_PURPLE" "$C_RESET" "$C_DIM" "$(ui_duration $(($(ui_now) - UI_INSTALL_STARTED)))" "$C_RESET" >&3 +if [ -n "$IPV4_PUBLIC_IP" ]; then + printf ' Public IPv4 %shttp://%s:8000%s\n' "$C_BOLD" "$IPV4_PUBLIC_IP" "$C_RESET" >&3 +fi +if [ -n "$IPV6_PUBLIC_IP" ]; then + printf ' Public IPv6 %shttp://[%s]:8000%s\n' "$C_BOLD" "$IPV6_PUBLIC_IP" "$C_RESET" >&3 fi -echo -e "\nWARNING: It is highly recommended to backup your Environment variables file (/data/coolify/source/.env) to a safe location, outside of this server (e.g. into a Password Manager).\n" +PRIVATE_IP_LABEL="Private IP " +for IP in $PRIVATE_IPS; do + if [ "$IP" != "$DEFAULT_PRIVATE_IP" ]; then + printf ' %s %shttp://%s:8000%s\n' "$PRIVATE_IP_LABEL" "$C_DIM" "$IP" "$C_RESET" >&3 + PRIVATE_IP_LABEL=" " + fi +done + +printf '\n %s! Back up %s/data/coolify/source/.env%s%s to a safe place outside this server (e.g. a password manager).%s\n' "$C_YELLOW" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$C_RESET" >&3 +printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3 log_section "Installation Complete" log "Coolify installation completed successfully" diff --git a/resources/js/settings-sidebar-accordion.js b/resources/js/settings-sidebar-accordion.js index fc62865f0b..b429136d85 100644 --- a/resources/js/settings-sidebar-accordion.js +++ b/resources/js/settings-sidebar-accordion.js @@ -1,9 +1,9 @@ // Alpine data provider for the collapsible resource settings sidebar // (x-data="settingsSidebarAccordion({ activeGroup, storageKey })"). // -// Only the group that contains the current page is open by default; every group -// can be collapsed/expanded and the choice is remembered per resource type. The -// active group is always forced open on load so the current page stays reachable. +// Every group is open by default; each group can be collapsed/expanded and the +// choice is remembered per resource type. The active group is always forced open +// on load so the current page stays reachable. export function initializeSettingsSidebarAccordionComponent() { window.Alpine.data('settingsSidebarAccordion', (config = {}) => ({ activeGroup: config.activeGroup || '', @@ -43,7 +43,7 @@ export function initializeSettingsSidebarAccordionComponent() { if (Object.prototype.hasOwnProperty.call(this.groups, group)) { return this.groups[group]; } - return false; + return true; }, toggle(group) { this.groups = { ...this.groups, [group]: !this.isOpen(group) }; diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 4a64a347ca..844786ce9d 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -179,7 +179,7 @@ ->values()) ->filter(fn ($items) => $items->isNotEmpty()); - // Group that holds the current page — the only one expanded by default. + // Group that holds the current page — always kept open, even if collapsed before. $activeGroup = (string) $groupedMenuItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false)); // In-page sections (cards) shown as sub-items under the active page diff --git a/resources/views/components/database/configuration-sidebar.blade.php b/resources/views/components/database/configuration-sidebar.blade.php index 1805efc863..20e5c51e03 100644 --- a/resources/views/components/database/configuration-sidebar.blade.php +++ b/resources/views/components/database/configuration-sidebar.blade.php @@ -46,7 +46,7 @@ ->values()) ->filter(fn ($items) => $items->isNotEmpty()); - // Group that holds the current page — the only one expanded by default. + // Group that holds the current page — always kept open, even if collapsed before. $activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false)); $pageSections = $database->type() === 'standalone-postgresql' diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index d91c852557..65fe3cabbc 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -187,8 +187,8 @@ ->values(); $groupedServerMenuItems = $serverMenuItems->groupBy('group'); - // Group that holds the current page (item or nested child) — the only one - // expanded by default. + // Group that holds the current page (item or nested child) — always kept + // open, even if collapsed before. $activeGroup = (string) $groupedServerMenuItems->search(fn ($items) => $items->contains( fn ($item) => ($item['active'] ?? false) || collect($item['children'] ?? [])->contains(fn ($child) => $child['active'] ?? false) diff --git a/resources/views/components/service/configuration-sidebar.blade.php b/resources/views/components/service/configuration-sidebar.blade.php index 6ad001c0c6..318b04b8d2 100644 --- a/resources/views/components/service/configuration-sidebar.blade.php +++ b/resources/views/components/service/configuration-sidebar.blade.php @@ -47,7 +47,7 @@ ->values()) ->filter(fn ($items) => $items->isNotEmpty()); - // Group that holds the current page — the only one expanded by default. + // Group that holds the current page — always kept open, even if collapsed before. $activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false)); @endphp diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index 617fabb073..10f1abf7d5 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -53,6 +53,17 @@ @if ($labelsAreWritable) Container label readonly mode is disabled. Domains must be set in the Labels section on the General page. + @unless ($isCompose) + + Go to Container labels + + @endunless @endif diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index 03dedd7991..fe2799e3bb 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -47,7 +47,7 @@ ->values()) ->filter(fn ($items) => $items->isNotEmpty()); - // Group that holds the current page — the only one expanded by default. + // Group that holds the current page — always kept open, even if collapsed before. $activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false)); $storageSections = $applications diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 5a6e514da6..04b10cb609 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -81,6 +81,99 @@ + @if ($server->proxyType() === ProxyTypes::TRAEFIK->value) + + + + Refresh + + + +
+ +
+ +
+ @if ($traefikCertificatesLoaded && count($traefikCertificates) === 0) + + @elseif (count($traefikCertificates) > 0) +
+
+ + + + + + + + + + + + @foreach ($traefikCertificates as $certificate) + + + + + + + + @endforeach + +
DomainResolverAlternative namesExpiresActions
+ {{ $certificate['main_domain'] }} + + {{ $certificate['resolver'] }} + @if ($certificate['store']) + + ({{ $certificate['store'] }}) + + @endif + + @if (count($certificate['sans']) > 0) +
+ @foreach ($certificate['sans'] as $domain) + + {{ $domain }} + + @endforeach +
+ @else + None + @endif +
+ {{ $certificate['expires_at'] ?? 'Unknown' }} + + @can('update', $server) + + @endcan +
+
+
+ @endif +
+
+ @endif + @php $proxyTitle = $server->proxyType() === ProxyTypes::TRAEFIK->value diff --git a/routes/webhooks.php b/routes/webhooks.php index 423b3cd713..cc5524de4f 100644 --- a/routes/webhooks.php +++ b/routes/webhooks.php @@ -14,13 +14,13 @@ Route::middleware(['web', 'auth', 'throttle:60,1'])->group(function () { }); Route::post('/source/github/events', [Github::class, 'normal']); -Route::post('/source/github/events/manual', [Github::class, 'manual'])->middleware('throttle:60,1'); +Route::post('/source/github/events/manual', [Github::class, 'manual']); Route::post('/source/gitlab/events', [Gitlab::class, 'normal']); -Route::post('/source/gitlab/events/manual', [Gitlab::class, 'manual'])->middleware('throttle:60,1'); +Route::post('/source/gitlab/events/manual', [Gitlab::class, 'manual']); -Route::post('/source/bitbucket/events/manual', [Bitbucket::class, 'manual'])->middleware('throttle:60,1'); +Route::post('/source/bitbucket/events/manual', [Bitbucket::class, 'manual']); -Route::post('/source/gitea/events/manual', [Gitea::class, 'manual'])->middleware('throttle:60,1'); +Route::post('/source/gitea/events/manual', [Gitea::class, 'manual']); Route::post('/payments/stripe/events', [Stripe::class, 'events']); diff --git a/scripts/install.sh b/scripts/install.sh index e2060b34d9..921a2940e4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -26,16 +26,222 @@ CURRENT_USER=$USER if [ $EUID != 0 ]; then echo "Please run this script as root or with sudo" - exit + exit 1 fi -echo "" +mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} +mkdir -p /data/coolify/images +mkdir -p /data/coolify/ssh/{keys,mux} +mkdir -p /data/coolify/proxy/dynamic + +chown -R 9999:root /data/coolify +chmod -R 700 /data/coolify + +INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log" + +# Terminal UI: the terminal only shows the step list, everything else goes to the log file +TOTAL_STEPS=9 +UI_STEP=0 +UI_STEP_TITLE="" +UI_STEP_DETAIL="" +UI_STEP_STARTED=0 +UI_SPINNER_PID="" +UI_WARNINGS=() + +if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ]; then + UI_TTY=true +else + UI_TTY=false +fi + +if [ "$UI_TTY" = true ] && [ -z "${NO_COLOR:-}" ]; then + C_RESET=$'\033[0m' + C_BOLD=$'\033[1m' + C_DIM=$'\033[2m' + C_PURPLE=$'\033[38;5;135m' + C_GREEN=$'\033[32m' + C_RED=$'\033[31m' + C_YELLOW=$'\033[33m' +else + C_RESET="" C_BOLD="" C_DIM="" C_PURPLE="" C_GREEN="" C_RED="" C_YELLOW="" +fi + +UI_WIDTH=$(tput cols 2>/dev/null || true) +if ! [[ $UI_WIDTH =~ ^[0-9]+$ ]]; then + UI_WIDTH=80 +fi +UI_WIDTH=$((UI_WIDTH > 100 ? 100 : UI_WIDTH < 60 ? 60 : UI_WIDTH)) + +# Current time in milliseconds +ui_now() { + if [ -n "${EPOCHREALTIME:-}" ]; then + local now=${EPOCHREALTIME/[.,]/} + echo $((now / 1000)) + else + echo $(($(date +%s) * 1000)) + fi +} + +ui_duration() { + local ms=$1 + if [ "$ms" -lt 60000 ]; then + printf '%d.%ds' $((ms / 1000)) $((ms % 1000 / 100)) + else + printf '%dm %ds' $((ms / 60000)) $((ms % 60000 / 1000)) + fi +} + +ui_repeat() { + local out="" i + for ((i = 0; i < $2; i++)); do + out+="$1" + done + printf '%s' "$out" +} + +# Prints the current step line without the trailing status symbol +ui_render_step() { + local right="$1" + local title="$UI_STEP_TITLE" + local detail="$UI_STEP_DETAIL" + local plain_detail="${detail//·/.}" + local left=$((7 + ${#title})) + local right_width=$((${#right} + 2)) + + if [ -n "$detail" ] && [ $((left + 2 + ${#plain_detail} + right_width + 2)) -le "$UI_WIDTH" ]; then + left=$((left + 2 + ${#plain_detail})) + else + detail="" + fi + + local pad=$((UI_WIDTH - left - right_width)) + if [ "$pad" -lt 2 ]; then + pad=2 + fi + printf ' %s%-5s%s%s%s%s' "$C_DIM" "${UI_STEP}/${TOTAL_STEPS}" "$C_RESET" "$C_BOLD" "$title" "$C_RESET" + if [ -n "$detail" ]; then + printf ' %s%s%s' "$C_DIM" "$detail" "$C_RESET" + fi + printf '%*s%s%s%s ' "$pad" "" "$C_DIM" "$right" "$C_RESET" +} + +ui_bar() { + local percent=$1 label=$2 + local cells=$((UI_WIDTH - 30 > 40 ? 40 : UI_WIDTH - 30)) + local filled=$((percent * cells / 100)) + printf ' %s%s%s%s%s %s%3d%%%s %s%s%s' \ + "$C_PURPLE" "$(ui_repeat ■ "$filled")" "$C_DIM" "$(ui_repeat ■ $((cells - filled)))" "$C_RESET" \ + "$C_BOLD" "$percent" "$C_RESET" "$C_DIM" "$label" "$C_RESET" +} + +# Runs in the background and animates the current step line and the progress bar below it +ui_spinner() { + set +x + local frames=(◜ ◠ ◝ ◞ ◡ ◟) i=0 stop=false + local line bar + trap 'stop=true' TERM + line=$(ui_render_step "") + bar=$(ui_bar $(((UI_STEP - 1) * 100 / TOTAL_STEPS)) "${UI_STEP_TITLE,,}…") + while [ "$stop" = false ]; do + printf '\r\033[2K%s%s%s%s\n\033[2K%s\033[1A\r' "$line" "$C_PURPLE" "${frames[i]}" "$C_RESET" "$bar" >&3 + i=$(((i + 1) % ${#frames[@]})) + sleep 0.12 + done +} + +ui_stop_spinner() { + if [ -n "$UI_SPINNER_PID" ]; then + kill "$UI_SPINNER_PID" 2>/dev/null || true + wait "$UI_SPINNER_PID" 2>/dev/null || true + UI_SPINNER_PID="" + fi +} + +# Shows a warning below the current step (or right away when no step is running) +warn() { + echo "WARNING: $*" + UI_WARNINGS+=("$*") +} + +ui_flush_warnings() { + local warning + for warning in "${UI_WARNINGS[@]}"; do + printf ' %s! %s%s\n' "$C_YELLOW" "$warning" "$C_RESET" >&3 + done + UI_WARNINGS=() +} + +step_start() { + UI_STEP=$((UI_STEP + 1)) + UI_STEP_TITLE="$1" + UI_STEP_DETAIL="${2:-}" + UI_STEP_STARTED=$(ui_now) + log_section "Step ${UI_STEP}/${TOTAL_STEPS}: $1" + if [ "$UI_TTY" = true ]; then + ui_spinner & + UI_SPINNER_PID=$! + fi +} + +# Usage: step_done [detail] - optionally replaces the detail text shown next to the step title +step_done() { + if [ -n "${1:-}" ]; then + UI_STEP_DETAIL="$1" + fi + ui_stop_spinner + local elapsed + elapsed=$(ui_duration $(($(ui_now) - UI_STEP_STARTED))) + log "Step ${UI_STEP}/${TOTAL_STEPS} completed in ${elapsed}" + if [ "$UI_TTY" = true ]; then + printf '\r\033[2K' >&3 + fi + printf '%s%s✓%s\n' "$(ui_render_step "$elapsed")" "$C_GREEN" "$C_RESET" >&3 + if [ "$UI_TTY" = true ]; then + printf '\033[2K' >&3 + fi + ui_flush_warnings + UI_STEP_TITLE="" +} + +ui_on_exit() { + local code=$? + ui_stop_spinner + if [ "$code" -ne 0 ]; then + if [ "$UI_TTY" = true ]; then + printf '\r\033[2K' >&3 + fi + if [ -n "$UI_STEP_TITLE" ]; then + printf '%s%s✗%s\n' "$(ui_render_step "$(ui_duration $(($(ui_now) - UI_STEP_STARTED)))")" "$C_RED" "$C_RESET" >&3 + fi + if [ "$UI_TTY" = true ]; then + printf '\033[2K' >&3 + fi + ui_flush_warnings + printf '\n %s%sInstallation failed.%s Last lines of the log:\n\n' "$C_BOLD" "$C_RED" "$C_RESET" >&3 + tail -n 15 "$INSTALLATION_LOG_WITH_DATE" 2>/dev/null | sed 's/^/ /' >&3 + printf '\n %sFull log: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3 + fi + if [ "$UI_TTY" = true ]; then + printf '\033[?25h' >&3 + fi +} + +# fd 3 is the terminal, stdout and stderr go to the log file +exec 3>&1 +exec >>"$INSTALLATION_LOG_WITH_DATE" 2>&1 +trap ui_on_exit EXIT + +if [ "$UI_TTY" = true ]; then + printf '\033[?25l' >&3 +fi +printf '\n %sWelcome to Coolify Installer!%s\n' "$C_BOLD" "$C_RESET" >&3 +printf ' %sThis script will install everything for you. Sit back and relax.%s\n' "$C_DIM" "$C_RESET" >&3 +printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3 +UI_INSTALL_STARTED=$(ui_now) + echo "==========================================" echo " Coolify Installation - ${DATE}" echo "==========================================" -echo "" -echo "Welcome to Coolify Installer!" -echo "This script will install everything for you. Sit back and relax." echo "Source code: https://github.com/coollabsio/coolify/blob/v4.x/scripts/install.sh" # Predefined root user @@ -157,9 +363,7 @@ if [ -f /etc/docker/daemon.json ]; then if [ "$DOCKER_POOL_FORCE_OVERRIDE" = true ]; then echo "Force override enabled - network pool will be updated with $DOCKER_ADDRESS_POOL_BASE/$DOCKER_ADDRESS_POOL_SIZE." else - echo "Custom pool provided but force override not enabled - using existing configuration." - echo "To force override, set DOCKER_POOL_FORCE_OVERRIDE=true" - echo "This won't change the existing docker networks, only the pool configuration for the newly created networks." + warn "Custom Docker network pool ignored, using the existing $EXISTING_POOL_BASE/$EXISTING_POOL_SIZE. Set DOCKER_POOL_FORCE_OVERRIDE=true to override it (existing networks are not changed)." DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE" DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE" DOCKER_POOL_BASE_PROVIDED=false @@ -172,7 +376,7 @@ fi # Validate Docker address pool configuration if ! [[ $DOCKER_ADDRESS_POOL_BASE =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then - echo "Warning: Invalid network pool base format: $DOCKER_ADDRESS_POOL_BASE" + warn "Invalid Docker network pool base: $DOCKER_ADDRESS_POOL_BASE" if [ "$EXISTING_POOL_CONFIGURED" = true ]; then echo "Using existing configuration: $EXISTING_POOL_BASE" DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE" @@ -183,7 +387,7 @@ if ! [[ $DOCKER_ADDRESS_POOL_BASE =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; fi if ! [[ $DOCKER_ADDRESS_POOL_SIZE =~ ^[0-9]+$ ]] || [ "$DOCKER_ADDRESS_POOL_SIZE" -lt 16 ] || [ "$DOCKER_ADDRESS_POOL_SIZE" -gt 28 ]; then - echo "Warning: Invalid network pool size: $DOCKER_ADDRESS_POOL_SIZE (must be 16-28)" + warn "Invalid Docker network pool size: $DOCKER_ADDRESS_POOL_SIZE (must be 16-28)" if [ "$EXISTING_POOL_CONFIGURED" = true ]; then echo "Using existing configuration: $EXISTING_POOL_SIZE" DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE" @@ -201,53 +405,20 @@ WARNING_SPACE=false if [ "$TOTAL_SPACE" -lt "$REQUIRED_TOTAL_SPACE" ]; then WARNING_SPACE=true - cat < >(tee -a $INSTALLATION_LOG_WITH_DATE) 2>&1 - -getAJoke() { - JOKES=$(curl -s --max-time 2 "https://v2.jokeapi.dev/joke/Programming?blacklistFlags=nsfw,religious,political,racist,sexist,explicit&format=txt&type=single" || true) - if [ "$JOKES" != "" ]; then - echo -e " - Until then, here's a joke for you:\n" - echo -e "$JOKES\n" - fi -} - # Helper function to log with timestamp log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" @@ -330,7 +501,7 @@ case "$OS_TYPE" in arch | ubuntu | debian | raspbian | centos | fedora | rhel | ol | rocky | sles | opensuse-leap | opensuse-tumbleweed | almalinux | amzn | alpine | postmarketos | tencentos) ;; *) echo "This script only supports Debian, Redhat, Arch Linux, Alpine Linux, or SLES based operating systems for now." - exit + exit 1 ;; esac @@ -349,10 +520,9 @@ echo "| Helper | $LATEST_HELPER_VERSION" echo "| Docker Pool | $DOCKER_ADDRESS_POOL_BASE (size $DOCKER_ADDRESS_POOL_SIZE)" echo "| Registry URL | $REGISTRY_URL" echo "---------------------------------------------" -echo "" -log_section "Step 1/9: Installing required packages" -echo "1/9 Installing required packages (curl, wget, git, jq, openssl)..." +ui_flush_warnings +step_start "Installing required packages" "curl wget git jq openssl" # Track if apt-get update was run to avoid redundant calls later APT_UPDATED=false @@ -360,6 +530,7 @@ APT_UPDATED=false if all_packages_installed; then log "All required packages already installed, skipping installation" echo " - All required packages already installed." + UI_STEP_DETAIL="already installed" else case "$OS_TYPE" in arch) @@ -394,15 +565,14 @@ else ;; *) echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now." - exit + exit 1 ;; esac log "Required packages installed successfully" fi -echo " Done." +step_done -log_section "Step 2/9: Checking OpenSSH server configuration" -echo "2/9 Checking OpenSSH server configuration..." +step_start "Checking OpenSSH server configuration" # Detect OpenSSH server SSH_DETECTED=false @@ -477,8 +647,7 @@ SSH_PERMIT_ROOT_LOGIN=$(sshd -T | grep -i "permitrootlogin" | awk '{print $2}') if [ "$SSH_PERMIT_ROOT_LOGIN" = "yes" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "without-password" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "prohibit-password" ]; then echo " - SSH PermitRootLogin is enabled." else - echo " - SSH PermitRootLogin is disabled." - echo " If you have problems with SSH, please read this: https://coolify.io/docs/knowledge-base/server/openssh" + warn "SSH PermitRootLogin is disabled. If you have problems with SSH, read https://coolify.io/docs/knowledge-base/server/openssh" fi # Detect if docker is installed via snap @@ -543,11 +712,11 @@ install_docker_from_rhel_repo() { systemctl --now enable docker } -log_section "Step 3/9: Checking Docker installation" -echo "3/9 Checking Docker installation..." +step_done + if ! [ -x "$(command -v docker)" ]; then + step_start "Installing Docker Engine" echo " - Docker is not installed. Installing Docker. It may take a while." - getAJoke case "$OS_TYPE" in "alpine" | "postmarketos") apk add docker docker-cli-compose >/dev/null 2>&1 @@ -617,6 +786,7 @@ if ! [ -x "$(command -v docker)" ]; then esac echo " - Docker installed successfully." else + step_start "Checking Docker Engine" echo " - Docker is installed." fi @@ -624,7 +794,7 @@ fi MIN_DOCKER_VERSION=24 INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1) if [ -z "$INSTALLED_DOCKER_VERSION" ]; then - echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed." + warn "Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed." elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer." echo " Please upgrade Docker: https://docs.docker.com/engine/install/" @@ -632,9 +802,10 @@ elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then else echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)." fi +DOCKER_SERVER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null || true) +step_done "${DOCKER_SERVER_VERSION:+v$DOCKER_SERVER_VERSION}" -log_section "Step 4/9: Checking Docker configuration" -echo "4/9 Checking Docker configuration..." +step_start "Configuring Docker daemon" "log rotation · address pools" echo " - Network pool configuration: ${DOCKER_ADDRESS_POOL_BASE}/${DOCKER_ADDRESS_POOL_SIZE}" echo " - To override existing configuration: DOCKER_POOL_FORCE_OVERRIDE=true" @@ -763,8 +934,9 @@ else fi fi -log_section "Step 5/9: Downloading required files from CDN" -echo "5/9 Downloading required files from CDN..." +step_done + +step_start "Downloading required files from CDN" log "Downloading configuration files in parallel..." # Download files in parallel for faster installation @@ -794,10 +966,9 @@ fi chmod +x /data/coolify/source/upgrade.sh /data/coolify/source/upgrade-postgres.sh log "All configuration files downloaded successfully" -echo " Done." +step_done -log_section "Step 6/9: Setting up environment variable file" -echo "6/9 Setting up environment variable file..." +step_start "Setting up environment variables" if [ -f "$ENV_FILE" ]; then # If .env exists, create backup @@ -813,10 +984,6 @@ else cp "/data/coolify/source/.env.production" "$ENV_FILE" fi log "Environment file setup completed" -echo " Done." - -log_section "Step 7/9: Checking and updating environment variables" -echo "7/9 Checking and updating environment variables..." update_env_var() { local key="$1" @@ -878,10 +1045,10 @@ else fi fi log "Environment variables check completed" -echo " Done." +step_done -log_section "Step 8/9: Checking SSH key for localhost access" -echo "8/9 Checking SSH key for localhost access..." +step_start "Checking SSH key for localhost access" +SSH_KEY_DETAIL="existing key" if [ ! -f ~/.ssh/authorized_keys ]; then mkdir -p ~/.ssh chmod 700 ~/.ssh @@ -895,6 +1062,7 @@ set -e if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then echo " - Generating SSH key." + SSH_KEY_DETAIL="generated new key" test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify @@ -907,20 +1075,18 @@ fi chown -R 9999:root /data/coolify chmod -R 700 /data/coolify log "SSH key check completed" -echo " Done." - -log_section "Step 9/9: Installing Coolify" -echo "9/9 Installing Coolify ($LATEST_VERSION)..." -echo -e " - It could take a while based on your server's performance, network speed, stars, etc." -echo -e " - Please wait." -getAJoke +step_done "$SSH_KEY_DETAIL" +step_start "Pulling images" "coolify · postgres · redis · helper" +# fd 3 is closed so the detached container restart in upgrade.sh does not hold the terminal open if [[ $- == *x* ]]; then - bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" + bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" 3>&- else - bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" + bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" 3>&- fi -echo " - Coolify installed successfully." +step_done + +step_start "Starting Coolify" "v${LATEST_VERSION}" echo " - Waiting for Coolify to be ready..." # Wait for upgrade.sh background process to complete @@ -1000,14 +1166,11 @@ if [ "$HEALTH" != "healthy" ]; then echo " - Please check: docker logs coolify" exit 1 fi -echo -e "\033[0;35m - ____ _ _ _ _ _ - / ___|___ _ __ __ _ _ __ __ _| |_ _ _| | __ _| |_(_) ___ _ __ ___| | - | | / _ \| '_ \ / _\` | '__/ _\` | __| | | | |/ _\` | __| |/ _ \| '_ \/ __| | - | |__| (_) | | | | (_| | | | (_| | |_| |_| | | (_| | |_| | (_) | | | \__ \_| - \____\___/|_| |_|\__, |_| \__,_|\__|\__,_|_|\__,_|\__|_|\___/|_| |_|___(_) - |___/ -\033[0m" +step_done + +if [ "$UI_TTY" = true ]; then + printf '%s\n' "$(ui_bar 100 "done")" >&3 +fi # Fetch public IPs in parallel for faster completion IPV4_TMP=$(mktemp) @@ -1022,29 +1185,29 @@ IPV4_PUBLIC_IP=$(cat "$IPV4_TMP" 2>/dev/null || true) IPV6_PUBLIC_IP=$(cat "$IPV6_TMP" 2>/dev/null || true) rm -f "$IPV4_TMP" "$IPV6_TMP" -echo -e "\nYour instance is ready to use!\n" -if [ -n "$IPV4_PUBLIC_IP" ]; then - echo -e "You can access Coolify through your Public IPV4: http://$IPV4_PUBLIC_IP:8000" -fi -if [ -n "$IPV6_PUBLIC_IP" ]; then - echo -e "You can access Coolify through your Public IPv6: http://[$IPV6_PUBLIC_IP]:8000" -fi - set +e DEFAULT_PRIVATE_IP=$(ip route get 1 | sed -n 's/^.*src \([0-9.]*\) .*$/\1/p') PRIVATE_IPS=$(hostname -I 2>/dev/null || ip -o addr show scope global | awk '{print $4}' | cut -d/ -f1) set -e -if [ -n "$PRIVATE_IPS" ]; then - echo -e "\nIf your Public IP is not accessible, you can use the following Private IPs:\n" - for IP in $PRIVATE_IPS; do - if [ "$IP" != "$DEFAULT_PRIVATE_IP" ]; then - echo -e "http://$IP:8000" - fi - done +printf '\n %s%sCoolify is ready!%s %sInstalled in %s%s\n\n' "$C_BOLD" "$C_PURPLE" "$C_RESET" "$C_DIM" "$(ui_duration $(($(ui_now) - UI_INSTALL_STARTED)))" "$C_RESET" >&3 +if [ -n "$IPV4_PUBLIC_IP" ]; then + printf ' Public IPv4 %shttp://%s:8000%s\n' "$C_BOLD" "$IPV4_PUBLIC_IP" "$C_RESET" >&3 +fi +if [ -n "$IPV6_PUBLIC_IP" ]; then + printf ' Public IPv6 %shttp://[%s]:8000%s\n' "$C_BOLD" "$IPV6_PUBLIC_IP" "$C_RESET" >&3 fi -echo -e "\nWARNING: It is highly recommended to backup your Environment variables file (/data/coolify/source/.env) to a safe location, outside of this server (e.g. into a Password Manager).\n" +PRIVATE_IP_LABEL="Private IP " +for IP in $PRIVATE_IPS; do + if [ "$IP" != "$DEFAULT_PRIVATE_IP" ]; then + printf ' %s %shttp://%s:8000%s\n' "$PRIVATE_IP_LABEL" "$C_DIM" "$IP" "$C_RESET" >&3 + PRIVATE_IP_LABEL=" " + fi +done + +printf '\n %s! Back up %s/data/coolify/source/.env%s%s to a safe place outside this server (e.g. a password manager).%s\n' "$C_YELLOW" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$C_RESET" >&3 +printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3 log_section "Installation Complete" log "Coolify installation completed successfully" diff --git a/tests/Feature/AdvisorySecurityRegressionTest.php b/tests/Feature/AdvisorySecurityRegressionTest.php index d35602ea4b..62af383364 100644 --- a/tests/Feature/AdvisorySecurityRegressionTest.php +++ b/tests/Feature/AdvisorySecurityRegressionTest.php @@ -60,10 +60,26 @@ it('does not apply the REST API allowlist to MCP and MCP switch routes', functio ->and($disable->gatherMiddleware())->not->toContain(ApiAllowed::class); }); -it('throttles every manual webhook route', function (string $provider) { +it('throttles only failed authentication on manual webhook routes', function (string $provider) { $route = Route::getRoutes()->match(Request::create("/webhooks/source/{$provider}/events/manual", 'POST')); + $request = Request::create("/webhooks/source/{$provider}/events/manual", 'POST', server: ['REMOTE_ADDR' => '192.0.2.44']); + $helper = new class + { + use MatchesManualWebhookApplications; - expect($route->gatherMiddleware())->toContain('throttle:60,1'); + public function reply(array $payloads, Request $request, string $provider): int + { + return $this->manualWebhookResponse(collect($payloads), $request, $provider)->getStatusCode(); + } + }; + + expect($route->gatherMiddleware())->not->toContain('throttle:60,1'); + + $helper->reply([['status' => 'success', 'message' => 'queued']], $request, $provider); + expect(RateLimiter::attempts("manual-webhook-failures:{$provider}:192.0.2.44"))->toBe(0); + + $helper->reply([['status' => 'failed', 'message' => 'Invalid signature.']], $request, $provider); + expect(RateLimiter::attempts("manual-webhook-failures:{$provider}:192.0.2.44"))->toBe(1); })->with(['github', 'gitlab', 'bitbucket', 'gitea']); it('does not reveal how many applications share a manual webhook repository', function () { @@ -73,7 +89,7 @@ it('does not reveal how many applications share a manual webhook repository', fu public function reply(array $payloads): string { - return $this->manualWebhookResponse(collect($payloads))->getContent(); + return $this->manualWebhookResponse(collect($payloads), Request::create('/webhooks/source/github/events/manual', 'POST'), 'github')->getContent(); } }; $failure = ['status' => 'failed', 'message' => 'Invalid signature.']; diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index a76cfbf7f6..5c20e35aa7 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -278,6 +278,7 @@ function makeControlVarFilteringJob(Application $application, Server $server, ar 'mainServer' => $server, 'pull_request_id' => 0, 'commit' => 'HEAD', + 'basedir' => '/artifacts/test-app', 'workdir' => '/artifacts/test-app', 'deployment_uuid' => 'deployment-uuid', 'dockerfile_location' => '/Dockerfile', @@ -1078,6 +1079,7 @@ it('checks compose Dockerfiles with a portable command that skips missing files' try { [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'basedir' => $workdir, 'workdir' => $workdir, 'env_args' => collect(['APP_ENV' => 'production']), ]); diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 74c9db670f..a457a2000b 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -607,6 +607,27 @@ it('does not overwrite a completed preview dns result with stale checking state' ]); }); +it('links the labels warning to the container labels section', function () { + $this->application->settings()->update(['is_container_label_readonly_enabled' => false]); + + $labelsUrl = route('project.application.configuration', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + ]).'#container-labels-section'; + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSee('Domains managed via labels') + ->assertSee('href="'.$labelsUrl.'"', false) + ->assertSee('Go to Container labels'); +}); + +it('does not show the labels warning when Coolify manages labels', function () { + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertDontSee('Domains managed via labels') + ->assertDontSee('#container-labels-section', false); +}); + it('lists existing domains as individual rows', function () { $this->application->update([ 'fqdn' => 'https://example.com,https://www.example.com,https://another.example.com,https://www.another.example.com', @@ -1021,17 +1042,16 @@ it('deletes the managed dns record when removing a domain by key with deleteMana 'provider_zone_id' => 'zone-1', 'name' => 'example.com', ]); - $record = ManagedDnsRecord::factory()->create([ + $record = ManagedDnsRecord::factory()->owned()->create([ 'team_id' => $this->team->id, 'integration_token_id' => $token->id, 'dns_provider_zone_id' => $zone->id, - 'resource_type' => $this->application->getMorphClass(), - 'resource_id' => $this->application->getKey(), 'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]); + $record->addReference($this->application); Http::fake(['https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-1' => Http::sequence() ->push(['success' => true, 'result' => [ @@ -1039,6 +1059,7 @@ it('deletes the managed dns record when removing a domain by key with deleteMana 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', + 'comment' => $record->ownershipComment(), ]]) ->push(['success' => true, 'result' => ['id' => 'record-1']])]); diff --git a/tests/Feature/ComposeBuildContextDeploymentTest.php b/tests/Feature/ComposeBuildContextDeploymentTest.php new file mode 100644 index 0000000000..581179a47f --- /dev/null +++ b/tests/Feature/ComposeBuildContextDeploymentTest.php @@ -0,0 +1,159 @@ + bash -c ...` + * into a local `bash -c ...`, so the real ARG injection commands run against a temporary repository. + */ +class LocallyExecutedComposeDeploymentJob extends ApplicationDeploymentJob +{ + public string $binaries; + + public array $logEntries = []; + + public function __construct() {} + + public function execute_remote_command(...$commands) + { + $savedOutputs = (new ReflectionProperty(ApplicationDeploymentJob::class, 'saved_outputs'))->getValue($this); + foreach ($commands as $command) { + $process = new Process(['/bin/bash', '-c', $command[0]], env: ['PATH' => $this->binaries.':'.getenv('PATH')]); + $process->run(); + if (isset($command['save'])) { + $savedOutputs->put($command['save'], trim($process->getOutput())); + } + } + } +} + +beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0]); + $this->root = sys_get_temp_dir().'/coolify-compose-context-'.bin2hex(random_bytes(4)); + $this->basedir = $this->root.'/artifacts/deployment'; + mkdir($this->basedir.'/apps/web', 0755, true); + mkdir($this->root.'/bin'); + file_put_contents($this->root.'/bin/docker', "#!/bin/sh\nif [ \"\$1\" = exec ]; then shift 2; exec \"\$@\"; fi\nexit 1\n"); + chmod($this->root.'/bin/docker', 0755); +}); + +afterEach(function () { + (new Process(['rm', '-rf', $this->root]))->run(); +}); + +function composeDockerfile(string $path): string +{ + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + file_put_contents($path, "FROM alpine\nRUN echo build\n"); + + return $path; +} + +function runComposeArgInjection(object $test, mixed $build, string $workdir): LocallyExecutedComposeDeploymentJob +{ + $application = Application::factory()->create(['build_pack' => 'dockercompose']); + $application->settings()->update(['inject_build_args_to_dockerfile' => true]); + + $job = new LocallyExecutedComposeDeploymentJob; + $job->binaries = $test->root.'/bin'; + $queue = Mockery::mock(ApplicationDeploymentQueue::class); + $queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message) use ($job) { + $job->logEntries[] = $message; + }); + + foreach ([ + 'application' => $application->fresh(), + 'application_deployment_queue' => $queue, + 'deployment_uuid' => 'deployment-uuid', + 'basedir' => $test->basedir, + 'workdir' => $workdir, + 'env_args' => collect(['APP_ENV' => 'production']), + 'saved_outputs' => collect(), + 'dockerSecretsSupported' => false, + 'pull_request_id' => 0, + ] as $property => $value) { + (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value); + } + + (new ReflectionMethod(ApplicationDeploymentJob::class, 'modify_dockerfiles_for_compose')) + ->invoke($job, ['services' => ['api' => ['build' => $build]]]); + + return $job; +} + +test('compose deployments inject build args for Dockerfiles inside the repository', function (mixed $build, string $workdir, string $dockerfile) { + $dockerfile = composeDockerfile($this->basedir.'/'.$dockerfile); + // The build context folder always exists in a real repository. + @mkdir(rtrim($this->basedir.'/'.$workdir, '/').'/'.(is_array($build) ? $build['context'] : $build), 0755, true); + + $job = runComposeArgInjection($this, $build, rtrim($this->basedir.'/'.$workdir, '/')); + + expect(file_get_contents($dockerfile))->toContain("FROM alpine\nARG APP_ENV") + ->and($job->logEntries)->toContain('Added 1 ARG declarations to Dockerfile for service api.'); +})->with([ + 'context in the project directory' => ['.', '', 'Dockerfile'], + 'monorepo context above the base directory' => [['context' => '../..'], 'apps/web', 'Dockerfile'], + 'path with a space' => ['my app', '', 'my app/Dockerfile'], + 'custom Dockerfile in a parent folder' => [['context' => 'services/api', 'dockerfile' => '../docker/api.Dockerfile'], '', 'services/docker/api.Dockerfile'], +]); + +test('compose deployments skip build contexts they cannot inspect locally', function (mixed $build) { + $dockerfile = composeDockerfile($this->basedir.'/Dockerfile'); + + $job = runComposeArgInjection($this, $build, $this->basedir); + + expect(file_get_contents($dockerfile))->not->toContain('ARG APP_ENV') + ->and($job->logEntries)->toContain('The build context of service api is remote or uses variables, skipping ARG injection.'); +})->with([ + 'git URL' => ['https://github.com/coollabsio/coolify.git#main:docker'], + 'context variable' => ['${APP_DIR:-.}'], + 'inline Dockerfile' => [['context' => '.', 'dockerfile_inline' => "FROM alpine\n"]], +]); + +test('compose deployments never change Dockerfiles outside the repository', function (string $escape) { + $outside = composeDockerfile($this->root.'/outside/Dockerfile'); + symlink($this->root.'/outside', $this->basedir.'/linked'); + $build = match ($escape) { + 'parent folder' => ['context' => '../../outside'], + 'symlink' => 'linked', + 'absolute path' => ['context' => $this->root.'/outside'], + }; + + $job = runComposeArgInjection($this, $build, $this->basedir); + + expect(file_get_contents($outside))->not->toContain('ARG APP_ENV') + ->and(collect($job->logEntries)->contains(fn (string $entry) => str_starts_with($entry, 'Dockerfile not found for service api')))->toBeTrue(); +})->with(['parent folder', 'symlink', 'absolute path']); + +test('compose build paths never run as shell commands', function (string $field, string $template) { + composeDockerfile($this->basedir.'/Dockerfile'); + $marker = $this->root.'/pwned'; + $value = str_replace('MARKER', $marker, $template); + $build = $field === 'context' ? ['context' => $value] : ['context' => '.', 'dockerfile' => $value]; + + runComposeArgInjection($this, $build, $this->basedir); + + expect(file_exists($marker))->toBeFalse(); +})->with([ + 'context semicolon' => ['context', '.; touch MARKER'], + 'context command substitution' => ['context', '$(touch MARKER)'], + 'context backticks' => ['context', '`touch MARKER`'], + 'context newline' => ['context', ".\ntouch MARKER"], + 'dockerfile semicolon' => ['dockerfile', 'Dockerfile; touch MARKER'], + 'dockerfile quote' => ['dockerfile', "Dockerfile'; touch MARKER; '"], + 'dockerfile newline' => ['dockerfile', "Dockerfile\ntouch MARKER"], +]); + +test('compose deployments do not reject build paths before the build', function () { + // Compose resolves build contexts itself; only the ARG injection step inspects them, safely. + expect(method_exists(ApplicationDeploymentJob::class, 'validateComposeBuildPaths'))->toBeFalse(); +}); diff --git a/tests/Feature/ComposeFileLoadCommandsTest.php b/tests/Feature/ComposeFileLoadCommandsTest.php new file mode 100644 index 0000000000..fae03551cc --- /dev/null +++ b/tests/Feature/ComposeFileLoadCommandsTest.php @@ -0,0 +1,75 @@ + 0]); + $this->root = sys_get_temp_dir().'/coolify-compose-load-'.bin2hex(random_bytes(4)); + mkdir($this->root.'/src/apps/web', 0755, true); + mkdir($this->root.'/bin'); + file_put_contents($this->root.'/bin/sudo', "#!/bin/sh\nexec \"\$@\"\n"); + chmod($this->root.'/bin/sudo', 0755); + + $this->compose = "services:\n api:\n build:\n context: ../..\n"; + file_put_contents($this->root.'/src/apps/web/docker-compose.yml', $this->compose); + file_put_contents($this->root.'/src/README.md', 'monorepo'); + foreach ([ + ['git', 'init', '-q', '-b', 'main'], + ['git', 'add', '-A'], + ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'monorepo'], + ['git', 'clone', '-q', '--bare', '.', $this->root.'/repo.git'], + ] as $command) { + (new Process($command, $this->root.'/src'))->mustRun(); + } + $this->commit = trim((new Process(['git', 'rev-parse', 'HEAD'], $this->root.'/src'))->mustRun()->getOutput()); +}); + +afterEach(function () { + (new Process(['rm', '-rf', $this->root]))->run(); + Mockery::close(); +}); + +test('the Compose file loads through the server commands for root and non-root users', function (string $user, bool $pinCommit) { + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'git_repository' => $this->root.'/repo.git', + 'git_branch' => 'main', + 'git_commit_sha' => $pinCommit ? $this->commit : 'HEAD', + 'base_directory' => '/apps/web', + 'docker_compose_location' => '/docker-compose.yml', + ]); + $application->settings->update(['is_git_submodules_enabled' => true]); + $uuid = 'compose-load-'.bin2hex(random_bytes(4)); + $gitVersion = (string) str((new Process(['git', '--version']))->mustRun()->getOutput())->trim()->explode(' ')->last(); + + $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid, $gitVersion); + if ($user !== 'root') { + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn($user); + $server->shouldReceive('setAttribute')->andReturnSelf(); + $commands = collect(parseCommandsByLineForSudo($commands, $server)); + } + + // Servers read the command list from stdin with `bash -se`, like instant_remote_process() over SSH. + $process = new Process(['bash', '-se'], env: ['PATH' => $this->root.'/bin:'.getenv('PATH')]); + $process->setInput($commands->implode("\n")."\n"); + $process->run(); + (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run(); + + expect($commands->implode("\n"))->not->toContain("cd '/artifacts/") + ->and($process->getErrorOutput())->not->toContain('syntax error') + ->and($process->getExitCode())->toBe(0) + ->and($process->getOutput())->toBe($this->compose); +})->with([ + 'root' => ['root'], + 'non-root' => ['ubuntu'], +])->with([ + 'branch head' => [false], + 'pinned commit' => [true], +]); diff --git a/tests/Feature/ComposeFileLoadErrorTest.php b/tests/Feature/ComposeFileLoadErrorTest.php new file mode 100644 index 0000000000..9ada0c45f7 --- /dev/null +++ b/tests/Feature/ComposeFileLoadErrorTest.php @@ -0,0 +1,79 @@ + 0]); + config(['constants.ssh.mux_enabled' => false]); + Log::spy(); + + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $this->application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'git_repository' => 'https://github.com/coollabsio/private-repo', + 'git_branch' => 'main', + 'base_directory' => '/', + 'docker_compose_location' => '/docker-compose.yml', + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->gitError = "Cloning into 'checkout'...\nfatal: unable to access 'https://x-access-token:ghs_SECRET123@github.com/coollabsio/private-repo.git/': The requested URL returned error: 403 denied"; +}); + +function fakeComposeLoadServer(?string $failingStep, string $errorOutput): void +{ + Process::fake(function ($process) use ($failingStep, $errorOutput) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + if ($failingStep !== null && str_contains($command, $failingStep)) { + return Process::result(errorOutput: $errorOutput, exitCode: 128); + } + + return Process::result(output: str_contains($command, 'git --version') ? 'git version 2.43.0' : ''); + }); +} + +it('shows and logs why the Compose file could not be read, without credentials', function () { + fakeComposeLoadServer('sparse-checkout', $this->gitError); + + expect(fn () => $this->application->loadComposeFile()) + ->toThrow(function (RuntimeException $exception) { + expect($exception->getMessage()) + ->toContain('Failed to read the Docker Compose file from the repository.') + ->toContain('The requested URL returned error: 403 <b>denied</b>') + ->toContain('https://***@github.com/') + ->not->toContain('ghs_SECRET123') + ->not->toContain(''); + }); + + Log::shouldHaveReceived('warning')->withArgs(fn (string $message, array $context) => $message === 'Failed to read the Docker Compose file from the repository.' + && $context['application_uuid'] === $this->application->uuid + && str_contains($context['error'], 'The requested URL returned error: 403') + && ! str_contains(json_encode($context), 'ghs_SECRET123')); +}); + +it('shows and logs why the Git source could not be read, without credentials', function () { + fakeComposeLoadServer('ls-remote', $this->gitError); + + expect(fn () => $this->application->loadComposeFile()) + ->toThrow(function (RuntimeException $exception) { + expect($exception->getMessage()) + ->toContain('Failed to read Git source. Please verify repository access and try again.') + ->toContain('The requested URL returned error: 403') + ->not->toContain('ghs_SECRET123'); + }); + + Log::shouldHaveReceived('warning')->withArgs(fn (string $message, array $context) => $message === 'Failed to read Git source.' + && ! str_contains(json_encode($context), 'ghs_SECRET123')); +}); diff --git a/tests/Feature/DatabaseStartFailClosedTest.php b/tests/Feature/DatabaseStartFailClosedTest.php new file mode 100644 index 0000000000..37bb77c345 --- /dev/null +++ b/tests/Feature/DatabaseStartFailClosedTest.php @@ -0,0 +1,238 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'docker'] + ); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'db', + 'image' => 'postgres:17', + 'status' => 'exited', + 'environment_id' => $environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +/** + * A functional server that has no CA certificate and cannot generate one. + */ +function serverThatCannotProvideCaCertificate(int $teamId): Server +{ + $server = new class extends Server + { + public function isFunctional() + { + return true; + } + + public function ensureCaCertificate(): ?SslCertificate + { + return null; + } + }; + + return $server->forceFill(['id' => 999, 'uuid' => 'server-without-ca', 'team_id' => $teamId]); +} + +function queuedDatabaseStartActivity(string $databaseUuid, int $teamId): Activity +{ + return activity() + ->withProperties([ + 'type' => ActivityTypes::INLINE->value, + 'type_uuid' => $databaseUuid, + 'status' => ProcessStatus::QUEUED->value, + 'team_id' => $teamId, + 'operation' => 'database-start', + ]) + ->event(ActivityTypes::INLINE->value) + ->log('[]'); +} + +dataset('ssl database start actions', [ + 'postgresql' => [StartPostgresql::class, StandalonePostgresql::class], + 'mysql' => [StartMysql::class, StandaloneMysql::class], + 'mariadb' => [StartMariadb::class, StandaloneMariadb::class], + 'mongodb' => [StartMongodb::class, StandaloneMongodb::class], + 'redis' => [StartRedis::class, StandaloneRedis::class], + 'keydb' => [StartKeydb::class, StandaloneKeydb::class], + 'dragonfly' => [StartDragonfly::class, StandaloneDragonfly::class], +]); + +it('throws instead of silently returning when an SSL database has no CA certificate', function (string $action, string $model) { + Bus::fake(); + + $database = (new $model)->forceFill(['id' => 1, 'uuid' => 'ssl-db-uuid', 'enable_ssl' => true]); + $destination = new StandaloneDocker; + $destination->setRelation('server', serverThatCannotProvideCaCertificate($this->team->id)); + $database->setRelation('destination', $destination); + + expect(fn () => app($action)->handle($database)) + ->toThrow(DatabaseStartException::class, 'No CA certificate found'); + + // The old code queued a broken copy of the action via $this->dispatch('error', ...). + Bus::assertNothingDispatched(); +})->with('ssl database start actions'); + +it('marks the activity as failed with the start error when the job fails', function () { + Event::fake([DatabaseStatusChanged::class]); + $activity = queuedDatabaseStartActivity($this->database->uuid, $this->team->id); + + $job = new DatabaseStartJob(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null); + $job->failed(DatabaseStartException::missingCaCertificate()); + + $activity->refresh(); + expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value) + ->and(data_get($activity, 'properties.error'))->toContain('No CA certificate found') + ->and(data_get($activity, 'properties.exitCode'))->toBe(1) + ->and(RunRemoteProcess::decodeOutput($activity))->toContain('No CA certificate found'); +}); + +it('keeps a generic message for unexpected job failures', function () { + Event::fake([DatabaseStatusChanged::class]); + $activity = queuedDatabaseStartActivity($this->database->uuid, $this->team->id); + + $job = new DatabaseStartJob(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null); + $job->failed(new RuntimeException('SQLSTATE internal details')); + + $activity->refresh(); + expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value) + ->and(data_get($activity, 'properties.error'))->toBe('Database start failed.') + ->and(RunRemoteProcess::decodeOutput($activity))->not->toContain('SQLSTATE'); +}); + +it('fails the queued activity when the start action throws inside the job', function () { + Event::fake([DatabaseStatusChanged::class]); + StartPostgresql::shouldRun()->andThrow(DatabaseStartException::missingCaCertificate()); + $activity = queuedDatabaseStartActivity($this->database->uuid, $this->team->id); + + expect(fn () => DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null)) + ->toThrow(DatabaseStartException::class); + + $activity->refresh(); + expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value) + ->and(data_get($activity, 'properties.error'))->toContain('No CA certificate found'); +}); + +it('fails the activity when the start action returns without running the start commands', function () { + Event::fake([DatabaseStatusChanged::class]); + StartPostgresql::shouldRun()->andReturnNull(); + $activity = queuedDatabaseStartActivity($this->database->uuid, $this->team->id); + + expect(fn () => DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null)) + ->toThrow(DatabaseStartException::class); + + $activity->refresh(); + expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value); +}); + +it('marks the activity as failed and rethrows when queueing the start job fails', function () { + Bus::shouldReceive('dispatch')->andThrow(new RuntimeException('Queue connection refused')); + + expect(fn () => StartDatabase::run($this->database)) + ->toThrow(RuntimeException::class, 'Queue connection refused'); + + $activity = Activity::query()->where('properties->type_uuid', $this->database->uuid)->sole(); + expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value) + ->and(data_get($activity, 'properties.error'))->toBe('Database start could not be queued.') + ->and(data_get($activity, 'properties.exitCode'))->toBe(1); +}); + +it('returns an immediate error without queueing when SSL is enabled and no CA certificate is available', function () { + Bus::fake(); + $this->database->forceFill(['enable_ssl' => true])->save(); + $destination = new StandaloneDocker; + $destination->setRelation('server', serverThatCannotProvideCaCertificate($this->team->id)); + $this->database->setRelation('destination', $destination); + + $result = StartDatabase::run($this->database); + + expect($result)->toBeString()->toContain('No CA certificate found'); + expect(Activity::query()->where('properties->type_uuid', $this->database->uuid)->exists())->toBeFalse(); + Bus::assertNotDispatched(DatabaseStartJob::class); +}); + +it('does not stop the database on restart when the start prerequisites are missing', function () { + Bus::fake(); + StopDatabase::shouldRun()->never(); + $this->database->forceFill(['enable_ssl' => true])->save(); + $destination = new StandaloneDocker; + $destination->setRelation('server', serverThatCannotProvideCaCertificate($this->team->id)); + $this->database->setRelation('destination', $destination); + + $result = RestartDatabase::run($this->database); + + expect($result)->toBeString()->toContain('No CA certificate found'); +}); + +it('queues the start when SSL is enabled and the server already has a CA certificate', function () { + Bus::fake(); + $this->database->forceFill(['enable_ssl' => true])->save(); + SslCertificate::create([ + 'ssl_certificate' => 'ca-cert', + 'ssl_private_key' => 'ca-key', + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYear(), + 'is_ca_certificate' => true, + 'server_id' => $this->server->id, + ]); + + $result = StartDatabase::run($this->database); + + expect($result)->toBeInstanceOf(Activity::class); + Bus::assertDispatched(DatabaseStartJob::class); +}); diff --git a/tests/Feature/DeleteResourceJobAtomicityTest.php b/tests/Feature/DeleteResourceJobAtomicityTest.php index 08ee57367f..8315d3f2a9 100644 --- a/tests/Feature/DeleteResourceJobAtomicityTest.php +++ b/tests/Feature/DeleteResourceJobAtomicityTest.php @@ -12,6 +12,7 @@ use App\Models\ScheduledVolumeBackup; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\Team; use App\Notifications\Internal\GeneralNotification; @@ -179,9 +180,39 @@ it('targets a service subresource container by its Docker labels', function () { expect($commands->implode("\n")) ->toContain("label=coolify.serviceId={$service->id}") ->toContain("label=coolify.service.subId={$application->id}") + ->toContain('label=coolify.service.subType=application') ->toContain('docker rm -f $container_ids'); }); +it('removes only the database container when an application of the same service has the same id', function () { + // Service applications and databases are separate tables, so they can share an id. + $service = Service::factory()->create([ + 'environment_id' => $this->application->environment_id, + 'server_id' => $this->application->destination->server_id, + 'destination_id' => $this->application->destination_id, + 'destination_type' => $this->application->destination_type, + ]); + $application = ServiceApplication::create(['service_id' => $service->id, 'name' => 'web', 'image' => 'nginx:alpine']); + $database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db', 'image' => 'postgres:17-alpine']); + $database->forceFill(['id' => $application->id])->save(); + $privateKey = PrivateKey::factory()->create(['team_id' => $service->server->team_id]); + $service->server->update(['private_key_id' => $privateKey->id]); + $service->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $commands = collect(); + Process::fake(function ($process) use ($commands) { + $commands->push($process->command); + + return Process::result(output: ''); + }); + + app(DeleteService::class)->removeSubresourceContainer($database->fresh()); + + expect($commands->implode("\n")) + ->toContain("label=coolify.service.subId={$application->id}") + ->toContain('label=coolify.service.subType=database') + ->not->toContain('label=coolify.service.subType=application'); +}); + it('rolls back local metadata deletion when deleting the resource fails', function () { Process::fake(['*' => Process::result(output: '')]); $applicationUuid = $this->application->uuid; diff --git a/tests/Feature/DnsProviderManagementTest.php b/tests/Feature/DnsProviderManagementTest.php index 1b1bfb0734..213ec3a515 100644 --- a/tests/Feature/DnsProviderManagementTest.php +++ b/tests/Feature/DnsProviderManagementTest.php @@ -1,5 +1,6 @@ createRecord($zone, 'app.example.com', '203.0.113.10'); - expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10'); + expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10') + ->and($record->owned)->toBeTrue(); Http::assertSent(fn ($request) => $request->method() === 'POST' && $request->data()['name'] === 'app.example.com' - && $request->data()['content'] === '203.0.113.10'); + && $request->data()['content'] === '203.0.113.10' + && $request->data()['comment'] === $record->ownershipComment()); }); test('queued dns configuration creates the record and broadcasts completion', function () { @@ -201,6 +204,7 @@ test('an existing matching remote record is tracked without creating a new one', expect($record->provider_record_id)->toBe('record-1') ->and($record->content)->toBe('203.0.113.10') + ->and($record->owned)->toBeFalse() ->and(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue(); Http::assertNotSent(fn ($request) => $request->method() === 'POST'); }); @@ -260,28 +264,41 @@ test('an existing remote record with different content remains a conflict', func }); test('a managed record changed outside coolify is not deleted', function () { - $record = ManagedDnsRecord::factory()->create([ + $record = ManagedDnsRecord::factory()->owned()->create([ 'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]); Http::fake(['https://api.cloudflare.com/client/v4/zones/*/dns_records/record-1' => Http::response([ 'success' => true, - 'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.99'], + 'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.99', 'comment' => $record->ownershipComment()], ])]); - expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBeFalse()->and($record->fresh())->not->toBeNull(); + expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBe(ManagedDnsDeletionResult::ChangedExternally) + ->and($record->fresh())->not->toBeNull(); + Http::assertNotSent(fn ($request) => $request->method() === 'DELETE'); +}); + +test('a record coolify did not create is never deleted', function () { + $record = ManagedDnsRecord::factory()->create([ + 'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', + ]); + Http::fake(); + + expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBe(ManagedDnsDeletionResult::NotOwned) + ->and($record->fresh())->not->toBeNull(); + Http::assertNothingSent(); }); test('an unchanged managed record is deleted from cloudflare and coolify', function () { - $record = ManagedDnsRecord::factory()->create([ + $record = ManagedDnsRecord::factory()->owned()->create([ 'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]); Http::fake(['https://api.cloudflare.com/client/v4/zones/*/dns_records/record-1' => Http::sequence() - ->push(['success' => true, 'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10']]) + ->push(['success' => true, 'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => $record->ownershipComment()]]) ->push(['success' => true, 'result' => ['id' => 'record-1']])]); - expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBeTrue() + expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBe(ManagedDnsDeletionResult::Deleted) ->and(ManagedDnsRecord::query()->find($record->id))->toBeNull(); }); @@ -491,32 +508,30 @@ test('removing a domain deletes only the managed dns record for that resource', $token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); - $otherRecord = ManagedDnsRecord::factory()->create([ + $otherRecord = ManagedDnsRecord::factory()->owned()->create([ 'team_id' => $team->id, 'integration_token_id' => $token->id, 'dns_provider_zone_id' => $zone->id, - 'resource_type' => $otherApplication->getMorphClass(), - 'resource_id' => $otherApplication->getKey(), 'provider_record_id' => 'record-other', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]); - $ownRecord = ManagedDnsRecord::factory()->create([ + $otherRecord->addReference($otherApplication); + $ownRecord = ManagedDnsRecord::factory()->owned()->create([ 'team_id' => $team->id, 'integration_token_id' => $token->id, 'dns_provider_zone_id' => $zone->id, - 'resource_type' => $application->getMorphClass(), - 'resource_id' => $application->getKey(), 'provider_record_id' => 'record-own', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]); + $ownRecord->addReference($application); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/*/dns_records/record-own' => Http::sequence() - ->push(['success' => true, 'result' => ['id' => 'record-own', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10']]) + ->push(['success' => true, 'result' => ['id' => 'record-own', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => $ownRecord->ownershipComment()]]) ->push(['success' => true, 'result' => ['id' => 'record-own']]), 'https://api.cloudflare.com/client/v4/zones/*/dns_records/record-other' => Http::response([ 'success' => true, @@ -553,11 +568,11 @@ test('replaceRecord updates the cloudflare record when the conflict still matche $zone, 'record-1', 'app.example.com', '203.0.113.10', expectedCurrent: '198.51.100.50', ); - expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10'); - Http::assertSent(fn ($request) => $request->method() === 'PUT' + expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10') + ->and($record->owned)->toBeFalse(); + Http::assertSent(fn ($request) => $request->method() === 'PATCH' && str_ends_with($request->url(), '/dns_records/record-1') - && $request->data()['name'] === 'app.example.com' - && $request->data()['content'] === '203.0.113.10'); + && $request->data() === ['content' => '203.0.113.10']); }); test('replaceRecord rejects a stale or tampered conflict without updating dns', function (string $recordId, string $current) { @@ -576,7 +591,7 @@ test('replaceRecord rejects a stale or tampered conflict without updating dns', $zone, $recordId, 'app.example.com', '203.0.113.10', expectedCurrent: $current, ))->toThrow(RuntimeException::class, 'The DNS conflict is no longer available. Check the record again.'); - Http::assertNotSent(fn ($request) => $request->method() === 'PUT'); + Http::assertNotSent(fn ($request) => in_array($request->method(), ['PUT', 'PATCH'], true)); })->with([ 'wrong record id' => ['record-other', '198.51.100.50'], 'wrong current value' => ['record-1', '203.0.113.99'], @@ -606,10 +621,10 @@ test('replacing a managed dns record uses the server ip and live cloudflare reco ->call('replaceManagedDnsRecord', 'app.example.com', $zone->id) ->assertDispatched('success', 'DNS record replaced for app.example.com.'); - Http::assertSent(fn ($request) => $request->method() === 'PUT' + Http::assertSent(fn ($request) => $request->method() === 'PATCH' && str_ends_with($request->url(), '/dns_records/record-1') - && $request->data()['content'] === '203.0.113.10'); - expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->where('content', '203.0.113.10')->exists())->toBeTrue(); + && $request->data() === ['content' => '203.0.113.10']); + expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->where('content', '203.0.113.10')->where('owned', false)->exists())->toBeTrue(); }); test('replacing a managed dns record ignores a tampered conflict record id', function () { @@ -637,7 +652,7 @@ test('replacing a managed dns record ignores a tampered conflict record id', fun ->assertDispatched('error', 'The DNS conflict is no longer available. Check the record again.') ->assertSet('dnsProviderConflicts', []); - Http::assertNotSent(fn ($request) => $request->method() === 'PUT'); + Http::assertNotSent(fn ($request) => in_array($request->method(), ['PUT', 'PATCH'], true)); expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeFalse(); }); diff --git a/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php b/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php index 994c96398b..06bf612489 100644 --- a/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php +++ b/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php @@ -1,7 +1,9 @@ server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeTruthy(); }); + +it('opens the log drain page without writing an audit event', function () { + $this->withoutDefer(); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->assertOk() + ->assertSet('isLogDrainNewRelicEnabled', false); + + expect(AuditEvent::query()->where('event', 'like', 'ui.server.log_drain.%')->count())->toBe(0); +}); + +it('audits enabling and disabling a log drain with its provider', function () { + $this->withoutDefer(); + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + StopLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainNewRelicLicenseKey', 'abc123') + ->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com') + ->call('toggleLogDrain', 'newrelic') + ->call('toggleLogDrain', 'newrelic'); + + $events = AuditEvent::query()->whereIn('event', ['ui.server.log_drain.enabled', 'ui.server.log_drain.disabled'])->orderBy('id')->get(); + + expect($events->pluck('event')->all())->toBe(['ui.server.log_drain.enabled', 'ui.server.log_drain.disabled']) + ->and($events->pluck('metadata.provider')->all())->toBe(['newrelic', 'newrelic']); +}); diff --git a/tests/Feature/ManagedDnsRecordOwnershipTest.php b/tests/Feature/ManagedDnsRecordOwnershipTest.php new file mode 100644 index 0000000000..0047066997 --- /dev/null +++ b/tests/Feature/ManagedDnsRecordOwnershipTest.php @@ -0,0 +1,508 @@ +withoutVite(); + config()->set('app.maintenance.store', 'array'); + config()->set('app.id', 'test-instance'); + + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate( + ['id' => 0], + ['id' => 0, 'is_dns_validation_enabled' => false], + )); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'ip' => '203.0.113.10']); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $this->destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'], + )); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'secret']); + $this->zone = DnsProviderZone::factory()->for($this->token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); +}); + +/** + * Fakes the Cloudflare DNS record API with an in-memory record store. + * + * @param array> $records + */ +function fakeCloudflareDns(array $records = []): ArrayObject +{ + $state = new ArrayObject(['records' => collect($records)->keyBy('id')->all(), 'next' => 1]); + + Http::fake(function (Request $request) use ($state) { + if (! preg_match('#^https://api\.cloudflare\.com/client/v4/zones/[^/]+/dns_records(?:/([^/?]+))?(?:\?(.*))?$#', $request->url(), $matches)) { + return Http::response(['success' => false], 404); + } + $id = ($matches[1] ?? '') !== '' ? $matches[1] : null; + $records = $state['records']; + + if ($id === null && $request->method() === 'GET') { + parse_str($matches[2] ?? '', $query); + $result = collect($records)->filter(fn (array $record): bool => $record['name'] === ($query['name'] ?? null) + && $record['type'] === ($query['type'] ?? null))->values()->all(); + + return Http::response(['success' => true, 'result' => $result]); + } + if ($id === null && $request->method() === 'POST') { + $id = 'record-new-'.$state['next']; + $state['next']++; + $records[$id] = array_merge(['proxied' => false, 'ttl' => 1, 'comment' => null], $request->data(), ['id' => $id]); + $state['records'] = $records; + + return Http::response(['success' => true, 'result' => $records[$id]]); + } + if (! isset($records[$id])) { + return Http::response(['success' => false, 'errors' => [['code' => 81044, 'message' => 'Record does not exist.']]], 404); + } + if ($request->method() === 'PATCH') { + $records[$id] = array_merge($records[$id], $request->data()); + $state['records'] = $records; + } + if ($request->method() === 'DELETE') { + unset($records[$id]); + $state['records'] = $records; + + return Http::response(['success' => true, 'result' => ['id' => $id]]); + } + + return Http::response(['success' => true, 'result' => $records[$id]]); + }); + + return $state; +} + +function createDnsTestApplication(object $test, string $fqdn): Application +{ + $application = Application::factory()->create([ + 'environment_id' => $test->environment->id, + 'destination_id' => $test->destination->id, + 'destination_type' => $test->destination->getMorphClass(), + 'fqdn' => $fqdn, + 'build_pack' => 'nixpacks', + ]); + $application->settings()->update(['is_container_label_readonly_enabled' => true]); + + return $application->fresh(); +} + +function sentDnsRequests(string $method): int +{ + return Http::recorded(fn (Request $request) => $request->method() === $method + && str_contains($request->url(), 'api.cloudflare.com'))->count(); +} + +test('a created record carries the ownership comment and is owned', function () { + fakeCloudflareDns(); + $application = createDnsTestApplication($this, 'https://app.example.com'); + + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + expect($record->owned)->toBeTrue() + ->and($record->ownershipComment())->toBe("managed-by: coolify test-instance/{$record->uuid}") + ->and($record->references()->where('resource_id', $application->id)->exists())->toBeTrue(); + Http::assertSent(fn (Request $request) => $request->method() === 'POST' + && $request->data()['comment'] === $record->ownershipComment() + && $request->data()['proxied'] === false); +}); + +test('an adopted hand-made record is never deleted when the domain is removed', function () { + $cloudflare = fakeCloudflareDns([[ + 'id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', + 'proxied' => true, 'ttl' => 300, 'comment' => 'hand made', + ]]); + $application = createDnsTestApplication($this, 'https://app.example.com'); + + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + expect($record->owned)->toBeFalse()->and($record->references()->count())->toBe(1); + + Livewire::test(Domains::class, ['application' => $application]) + ->call('removeDomain', 0, '', ['deleteManagedDns']) + ->assertDispatched('success'); + + expect(sentDnsRequests('DELETE'))->toBe(0) + ->and(sentDnsRequests('POST'))->toBe(0) + ->and(sentDnsRequests('PATCH'))->toBe(0) + ->and($cloudflare['records'])->toHaveKey('record-1') + ->and(ManagedDnsRecord::query()->find($record->id))->toBeNull(); +}); + +test('replacing a hand-made record keeps proxied, ttl and comment and never deletes it later', function () { + $cloudflare = fakeCloudflareDns([[ + 'id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50', + 'proxied' => true, 'ttl' => 300, 'comment' => 'hand made', + ]]); + $application = createDnsTestApplication($this, 'https://app.example.com'); + + $record = app(CloudflareDnsProvider::class)->replaceRecord( + $this->zone, 'record-1', 'app.example.com', '203.0.113.10', $application, expectedCurrent: '198.51.100.50', + ); + + Http::assertSent(fn (Request $request) => $request->method() === 'PATCH' + && str_ends_with($request->url(), '/dns_records/record-1') + && $request->data() === ['content' => '203.0.113.10']); + expect($record->owned)->toBeFalse() + ->and($cloudflare['records']['record-1'])->toMatchArray([ + 'content' => '203.0.113.10', 'proxied' => true, 'ttl' => 300, 'comment' => 'hand made', + ]); + + Livewire::test(Domains::class, ['application' => $application]) + ->call('removeDomain', 0, '', ['deleteManagedDns']) + ->assertDispatched('success'); + + expect(sentDnsRequests('DELETE'))->toBe(0) + ->and($cloudflare['records'])->toHaveKey('record-1'); +}); + +test('an owned record is kept while another resource references it and deleted after the last reference', function () { + $cloudflare = fakeCloudflareDns(); + $first = createDnsTestApplication($this, 'https://app.example.com'); + $second = createDnsTestApplication($this, 'https://app.example.com/api'); + $provider = app(CloudflareDnsProvider::class); + + $record = $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $first); + $adopted = $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $second); + + expect($adopted->id)->toBe($record->id) + ->and($adopted->fresh()->owned)->toBeTrue() + ->and($record->references()->count())->toBe(2) + ->and(sentDnsRequests('POST'))->toBe(1); + + Livewire::test(Domains::class, ['application' => $first]) + ->call('removeDomain', 0, '', ['deleteManagedDns']) + ->assertDispatched('success'); + + expect(sentDnsRequests('DELETE'))->toBe(0) + ->and($record->fresh())->not->toBeNull() + ->and($record->references()->pluck('resource_id')->all())->toBe([$second->id]); + + Livewire::test(Domains::class, ['application' => $second]) + ->call('removeDomain', 0, '', ['deleteManagedDns']) + ->assertDispatched('success'); + + expect(sentDnsRequests('DELETE'))->toBe(1) + ->and($cloudflare['records'])->toBeEmpty() + ->and($record->fresh())->toBeNull(); +}); + +test('another application of the same team still using the hostname prevents deletion and takes over the reference', function () { + $cloudflare = fakeCloudflareDns(); + $application = createDnsTestApplication($this, 'https://app.example.com'); + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + $otherApplication = createDnsTestApplication($this, 'http://other.example.org,https://APP.example.com:8443'); + + Livewire::test(Domains::class, ['application' => $application]) + ->call('removeDomain', 0, '', ['deleteManagedDns']) + ->assertDispatched('success'); + + expect(sentDnsRequests('DELETE'))->toBe(0) + ->and($cloudflare['records'])->toHaveCount(1) + ->and($record->fresh())->not->toBeNull() + ->and($record->references()->pluck('resource_id')->all())->toBe([$otherApplication->id]); + + $otherApplication->delete(); + + expect(sentDnsRequests('DELETE'))->toBe(1) + ->and($cloudflare['records'])->toBeEmpty() + ->and($record->fresh())->toBeNull(); +}); + +test('an application of another team using the hostname prevents deletion', function () { + $cloudflare = fakeCloudflareDns(); + $application = createDnsTestApplication($this, 'https://app.example.com'); + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); + Application::factory()->create([ + 'environment_id' => $otherEnvironment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'fqdn' => 'https://app.example.com', + 'build_pack' => 'nixpacks', + ]); + + Livewire::test(Domains::class, ['application' => $application]) + ->call('removeDomain', 0, '', ['deleteManagedDns']) + ->assertDispatched('success'); + + expect(sentDnsRequests('DELETE'))->toBe(0) + ->and($cloudflare['records'])->toHaveCount(1) + ->and(ManagedDnsRecord::query()->find($record->id))->toBeNull(); +}); + +test('a hostname still used by a compose application or preview prevents deletion', function (string $usage) { + fakeCloudflareDns(); + $application = createDnsTestApplication($this, 'https://app.example.com'); + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + $other = createDnsTestApplication($this, 'https://unrelated.example.org'); + if ($usage === 'compose') { + $other->update(['docker_compose_domains' => json_encode(['web' => ['domain' => 'https://app.example.com']])]); + } else { + ApplicationPreview::query()->create([ + 'application_id' => $other->id, 'pull_request_id' => 7, 'pull_request_html_url' => 'https://github.com/x/y/pull/7', + 'fqdn' => 'https://app.example.com', + ]); + } + + Livewire::test(Domains::class, ['application' => $application]) + ->call('removeDomain', 0, '', ['deleteManagedDns']) + ->assertDispatched('success'); + + expect(sentDnsRequests('DELETE'))->toBe(0)->and($record->fresh())->not->toBeNull(); +})->with(['compose', 'preview']); + +test('an owned record whose remote comment was changed or removed is not deleted', function (?string $comment) { + $cloudflare = fakeCloudflareDns(); + $application = createDnsTestApplication($this, 'https://app.example.com'); + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + $records = $cloudflare['records']; + $records[$record->provider_record_id]['comment'] = $comment; + $cloudflare['records'] = $records; + + Livewire::test(Domains::class, ['application' => $application]) + ->call('removeDomain', 0, '', ['deleteManagedDns']) + ->assertDispatched('warning', 'The domain was removed, but its DNS record changed externally and was left untouched.'); + + expect(sentDnsRequests('DELETE'))->toBe(0) + ->and($cloudflare['records'])->toHaveKey($record->provider_record_id) + ->and($record->fresh())->toBeNull(); +})->with([ + 'removed' => [null], + 'changed' => ['production record'], + 'other coolify record' => ['managed-by: coolify test-instance/someotherrecord'], +]); + +test('removing a domain without deleting dns forgets the reference and keeps the remote record', function () { + $cloudflare = fakeCloudflareDns(); + $application = createDnsTestApplication($this, 'https://app.example.com'); + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + Livewire::test(Domains::class, ['application' => $application]) + ->call('removeDomain', 0, '', []) + ->assertDispatched('success'); + + expect(sentDnsRequests('DELETE'))->toBe(0) + ->and($cloudflare['records'])->toHaveCount(1) + ->and($record->fresh())->toBeNull(); +}); + +test('removing one of two urls with the same hostname keeps the reference', function () { + $cloudflare = fakeCloudflareDns(); + $application = createDnsTestApplication($this, 'https://app.example.com,http://app.example.com:8080'); + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + Livewire::test(Domains::class, ['application' => $application]) + ->call('removeDomain', 0, '', ['deleteManagedDns']) + ->assertDispatched('success'); + + expect(sentDnsRequests('DELETE'))->toBe(0) + ->and($record->fresh())->not->toBeNull() + ->and($record->references()->pluck('resource_id')->all())->toBe([$application->id]); +}); + +test('an existing round-robin record with the wanted content satisfies the request', function () { + fakeCloudflareDns([ + ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50', 'comment' => null], + ['id' => 'record-2', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => null], + ]); + $application = createDnsTestApplication($this, 'https://app.example.com'); + + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + expect($record->provider_record_id)->toBe('record-2')->and($record->owned)->toBeFalse(); + expect(sentDnsRequests('POST'))->toBe(0); +}); + +test('several round-robin records without the wanted content are not replaced', function () { + fakeCloudflareDns([ + ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50'], + ['id' => 'record-2', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.51'], + ]); + $provider = app(CloudflareDnsProvider::class); + + expect(fn () => $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10')) + ->toThrow(RuntimeException::class, 'Several DNS records already exist for app.example.com'); + expect(fn () => $provider->replaceRecord($this->zone, 'record-1', 'app.example.com', '203.0.113.10', expectedCurrent: '198.51.100.50')) + ->toThrow(RuntimeException::class); + + expect(sentDnsRequests('POST') + sentDnsRequests('PATCH') + sentDnsRequests('PUT'))->toBe(0); +}); + +test('deleting an application removes its owned dns records', function () { + $cloudflare = fakeCloudflareDns(); + $application = createDnsTestApplication($this, 'https://app.example.com'); + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + $application->delete(); + + expect(sentDnsRequests('DELETE'))->toBe(1) + ->and($cloudflare['records'])->toBeEmpty() + ->and($record->fresh())->toBeNull(); +}); + +test('deleting a service application or preview removes its owned dns records', function (string $kind) { + $cloudflare = fakeCloudflareDns(); + if ($kind === 'service application') { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + $resource = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'image' => 'nginx:alpine', + 'fqdn' => 'https://app.example.com', + ]); + } else { + $application = createDnsTestApplication($this, 'https://main.example.org'); + $resource = ApplicationPreview::query()->create([ + 'application_id' => $application->id, 'pull_request_id' => 3, 'pull_request_html_url' => 'https://github.com/x/y/pull/3', + 'fqdn' => 'https://app.example.com', + ]); + } + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $resource); + + $resource->delete(); + + expect(sentDnsRequests('DELETE'))->toBe(1) + ->and($cloudflare['records'])->toBeEmpty() + ->and($record->fresh())->toBeNull(); +})->with(['service application', 'preview']); + +test('deleting an application also releases the references of its previews', function () { + $cloudflare = fakeCloudflareDns(); + $application = createDnsTestApplication($this, 'https://main.example.org'); + $preview = ApplicationPreview::query()->create([ + 'application_id' => $application->id, 'pull_request_id' => 4, 'pull_request_html_url' => 'https://github.com/x/y/pull/4', + 'fqdn' => 'https://pr-4.example.com', + ]); + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'pr-4.example.com', '203.0.113.10', $preview); + + $application->forceDelete(); + + expect($cloudflare['records'])->toBeEmpty()->and($record->fresh())->toBeNull(); +}); + +test('resource deletion succeeds and keeps the record when cloudflare is unreachable', function () { + fakeCloudflareDns(); + $application = createDnsTestApplication($this, 'https://app.example.com'); + $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + Http::fake(fn () => throw new ConnectionException('Cloudflare is unreachable.')); + + $application->forceDelete(); + + expect(Application::withTrashed()->find($application->id))->toBeNull() + ->and($record->fresh())->not->toBeNull() + ->and($record->references()->count())->toBe(1); +}); + +test('deleting a resource dispatches the release job only when it references dns records', function () { + Queue::fake(); + fakeCloudflareDns(); + $withRecord = createDnsTestApplication($this, 'https://app.example.com'); + $withoutRecord = createDnsTestApplication($this, 'https://plain.example.com'); + app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $withRecord); + + $withoutRecord->delete(); + Queue::assertNotPushed(ReleaseManagedDnsRecordsJob::class); + + $withRecord->delete(); + Queue::assertPushed(ReleaseManagedDnsRecordsJob::class, fn (ReleaseManagedDnsRecordsJob $job): bool => $job->resourceType === $withRecord->getMorphClass() + && (string) $job->resourceId === (string) $withRecord->id); +}); + +test('service domain removal releases only the removed service application reference', function () { + $cloudflare = fakeCloudflareDns(); + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n", + ]); + $web = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'image' => 'nginx:alpine', + 'fqdn' => 'https://app.example.com', + ]); + $api = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'api', 'image' => 'nginx:alpine', + 'fqdn' => 'https://app.example.com/api', + ]); + $provider = app(CloudflareDnsProvider::class); + $record = $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $web); + $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $api); + + Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])]) + ->call('removeDomainByKey', hash('sha256', 'https://app.example.com/api|'.$api->id), '', ['deleteManagedDns']) + ->assertDispatched('success'); + + expect(sentDnsRequests('DELETE'))->toBe(0) + ->and($cloudflare['records'])->toHaveCount(1) + ->and($record->references()->pluck('resource_id')->all())->toBe([$web->id]); +}); + +test('releasing a hostname never touches records of another team', function () { + $cloudflare = fakeCloudflareDns([[ + 'id' => 'record-foreign', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => null, + ]]); + $application = createDnsTestApplication($this, 'https://app.example.com'); + + $otherToken = IntegrationToken::factory()->for(Team::factory()->create())->create(['provider' => 'cloudflare', 'token' => 'other']); + $otherZone = DnsProviderZone::factory()->for($otherToken)->create(['provider_zone_id' => 'zone-2', 'name' => 'example.com']); + $foreignRecord = ManagedDnsRecord::factory()->owned()->create([ + 'dns_provider_zone_id' => $otherZone->id, 'provider_record_id' => 'record-foreign', + 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', + ]); + $foreignRecord->addReference($application); + + Livewire::test(Domains::class, ['application' => $application]) + ->call('removeDomain', 0, '', ['deleteManagedDns']) + ->assertDispatched('success'); + + expect(Http::recorded())->toBeEmpty() + ->and($cloudflare['records'])->toHaveKey('record-foreign') + ->and($foreignRecord->fresh())->not->toBeNull() + ->and($foreignRecord->references()->count())->toBe(1); +}); diff --git a/tests/Feature/ProxyAcmeCertificateUiTest.php b/tests/Feature/ProxyAcmeCertificateUiTest.php new file mode 100644 index 0000000000..3e52a5afda --- /dev/null +++ b/tests/Feature/ProxyAcmeCertificateUiTest.php @@ -0,0 +1,36 @@ +toContain('TLS certificates') + ->toContain('wire:click="loadTraefikCertificates"') + ->toContain('submitAction="deleteTraefikCertificate') + ->toContain("@can('update', \$server)") + ->and($component) + ->toContain('public function loadTraefikCertificates(): void') + ->toContain('public function deleteTraefikCertificate(string $certificateId, string $password = \'\'): void') + ->toContain("\$this->authorize('update', \$this->server)") + ->toContain(GetTraefikCertificates::class) + ->toContain(DeleteTraefikCertificate::class); +}); + +it('uses bounded reads and atomic restricted writes for the ACME file', function () { + $reader = file_get_contents(app_path('Actions/Proxy/GetTraefikCertificates.php')); + $writer = file_get_contents(app_path('Actions/Proxy/DeleteTraefikCertificate.php')); + + expect($reader) + ->toContain('MAX_FILE_SIZE_BYTES') + ->toContain('head -c') + ->toContain('base64') + ->and($writer) + ->toContain('umask 077') + ->toContain('chmod 600') + ->toContain('mv --') + ->not->toContain('rm -f'); +}); diff --git a/tests/Feature/QueuedJobsReadFreshInstanceSettingsTest.php b/tests/Feature/QueuedJobsReadFreshInstanceSettingsTest.php new file mode 100644 index 0000000000..b8c14bfba0 --- /dev/null +++ b/tests/Feature/QueuedJobsReadFreshInstanceSettingsTest.php @@ -0,0 +1,22 @@ + 0, 'is_dns_validation_enabled' => false]); + + // The worker process memoizes the settings with once(). + expect((bool) instanceSettings()->is_dns_validation_enabled)->toBeFalse(); + + // Another process (the web UI) enables the setting. Its model events do not reach this process. + DB::table('instance_settings')->where('id', 0)->update(['is_dns_validation_enabled' => true]); + + dispatch(fn () => Cache::put('seen_dns_validation', (bool) instanceSettings()->is_dns_validation_enabled)); + + expect(Cache::get('seen_dns_validation'))->toBeTrue(); +}); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index dc07e204de..4f9c2d0382 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -189,17 +189,16 @@ it('deletes the managed dns record when removing a service domain by key with de 'provider_zone_id' => 'zone-1', 'name' => 'example.com', ]); - $record = ManagedDnsRecord::factory()->create([ + $record = ManagedDnsRecord::factory()->owned()->create([ 'team_id' => $this->team->id, 'integration_token_id' => $token->id, 'dns_provider_zone_id' => $zone->id, - 'resource_type' => $this->apiApp->getMorphClass(), - 'resource_id' => $this->apiApp->getKey(), 'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'api.example.com', 'content' => '203.0.113.10', ]); + $record->addReference($this->apiApp); Http::fake(['https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-1' => Http::sequence() ->push(['success' => true, 'result' => [ @@ -207,6 +206,7 @@ it('deletes the managed dns record when removing a service domain by key with de 'type' => 'A', 'name' => 'api.example.com', 'content' => '203.0.113.10', + 'comment' => $record->ownershipComment(), ]]) ->push(['success' => true, 'result' => ['id' => 'record-1']])]); diff --git a/tests/Feature/SettingsSidebarAccordionTest.php b/tests/Feature/SettingsSidebarAccordionTest.php index 85ff5dabf3..52348bcae5 100644 --- a/tests/Feature/SettingsSidebarAccordionTest.php +++ b/tests/Feature/SettingsSidebarAccordionTest.php @@ -1,8 +1,8 @@ 'resources/views/components/application/configuration-sidebar.blade.php', @@ -17,7 +17,7 @@ it('wires the collapsible accordion into every grouped settings sidebar', functi expect($contents) ->toContain('settingsSidebarAccordion(') // shared Alpine data provider - ->toContain('$activeGroup') // only the active group opens by default + ->toContain('$activeGroup') // the active group is always open ->toContain('nav-section-toggle') // group header is a toggle button ->toContain('toggle(') // header collapses/expands the group ->toContain("? 'xl:block' : 'xl:hidden'"); // desktop-only collapse wrapper @@ -66,3 +66,13 @@ it('keeps the group for the active page open', function () { ->toContain('if (group === this.activeGroup)') ->toContain('return true;'); }); + +it('expands every group by default when the user has not collapsed it', function () { + $accordion = file_get_contents(base_path('resources/js/settings-sidebar-accordion.js')); + + $isOpen = str($accordion)->after('isOpen(group) {')->before('toggle(group)')->toString(); + + expect($isOpen) + ->toContain('return this.groups[group];') + ->not->toContain('return false;'); +}); diff --git a/tests/Feature/StaleStartActivityTest.php b/tests/Feature/StaleStartActivityTest.php new file mode 100644 index 0000000000..0867fa7187 --- /dev/null +++ b/tests/Feature/StaleStartActivityTest.php @@ -0,0 +1,172 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'docker'] + ); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'db', + 'image' => 'postgres:17', + 'status' => 'exited', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $this->service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'server_id' => $this->server->id, + ]); +}); + +function startActivity(string $typeUuid, ProcessStatus $status, ?string $operation = null, int $minutesSinceLastUpdate = 0): Activity +{ + $activity = activity() + ->withProperties(array_filter([ + 'type' => ActivityTypes::INLINE->value, + 'type_uuid' => $typeUuid, + 'status' => $status->value, + 'operation' => $operation, + ])) + ->event(ActivityTypes::INLINE->value) + ->log('[]'); + + Activity::query()->whereKey($activity->id)->update([ + 'created_at' => now()->subMinutes($minutesSinceLastUpdate), + 'updated_at' => now()->subMinutes($minutesSinceLastUpdate), + ]); + + return $activity->refresh(); +} + +function databaseHeadingFor(StandalonePostgresql $database): DatabaseHeading +{ + $heading = new DatabaseHeading; + $heading->database = $database; + + return $heading; +} + +function serviceHeadingFor(Service $service): ServiceHeading +{ + $heading = new ServiceHeading; + $heading->service = $service; + + return $heading; +} + +dataset('blocking start activities', [ + 'freshly queued' => [ProcessStatus::QUEUED, 0], + 'queued for 9 minutes' => [ProcessStatus::QUEUED, 9], + 'in progress with recent output' => [ProcessStatus::IN_PROGRESS, 0], + 'in progress, silent for 14 minutes' => [ProcessStatus::IN_PROGRESS, 14], +]); + +dataset('stale start activities', [ + 'queued for 11 minutes' => [ProcessStatus::QUEUED, 11], + 'queued for a day' => [ProcessStatus::QUEUED, 60 * 24], + 'in progress, silent for 16 minutes' => [ProcessStatus::IN_PROGRESS, 16], +]); + +it('blocks database start and restart while a live start activity exists', function (ProcessStatus $status, int $minutes) { + $activity = startActivity($this->database->uuid, $status, 'database-start', $minutes); + $heading = databaseHeadingFor($this->database); + + expect($heading->checkDeployments())->toBeTrue() + ->and($heading->runningActivityId)->toBe($activity->id); +})->with('blocking start activities'); + +it('does not let a stale start activity block database start and restart', function (ProcessStatus $status, int $minutes) { + startActivity($this->database->uuid, $status, 'database-start', $minutes); + $heading = databaseHeadingFor($this->database); + + expect($heading->checkDeployments())->toBeFalse() + ->and($heading->runningActivityId)->toBeNull(); +})->with('stale start activities'); + +it('does not block database start once the latest activity finished', function () { + startActivity($this->database->uuid, ProcessStatus::FINISHED, 'database-start'); + + expect(databaseHeadingFor($this->database)->checkDeployments())->toBeFalse(); +}); + +it('reports a service as starting only while its start activity is live', function (ProcessStatus $status, int $minutes) { + startActivity($this->service->uuid, $status, minutesSinceLastUpdate: $minutes); + + expect($this->service->isStarting())->toBeTrue() + ->and(serviceHeadingFor($this->service)->checkDeployments())->toBeTrue(); +})->with('blocking start activities'); + +it('does not report a service as starting because of a stale start activity', function (ProcessStatus $status, int $minutes) { + startActivity($this->service->uuid, $status, minutesSinceLastUpdate: $minutes); + + expect($this->service->isStarting())->toBeFalse() + ->and(serviceHeadingFor($this->service)->checkDeployments())->toBeFalse(); +})->with('stale start activities'); + +it('marks interrupted database and service start activities as failed on startup', function () { + $queuedDatabaseStart = startActivity($this->database->uuid, ProcessStatus::QUEUED, 'database-start'); + $runningDatabaseStart = startActivity((string) Str::uuid(), ProcessStatus::IN_PROGRESS, 'database-start'); + $runningServiceStart = startActivity($this->service->uuid, ProcessStatus::IN_PROGRESS); + $finishedDatabaseStart = startActivity($this->database->uuid, ProcessStatus::FINISHED, 'database-start'); + $failedDatabaseStart = startActivity($this->database->uuid, ProcessStatus::ERROR, 'database-start'); + $unrelatedRunningProcess = startActivity('not-a-start-resource', ProcessStatus::IN_PROGRESS); + + expect(ResourceStartActivity::failInterrupted())->toBe(3); + + foreach ([$queuedDatabaseStart, $runningDatabaseStart, $runningServiceStart] as $activity) { + $activity->refresh(); + expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value) + ->and(data_get($activity, 'properties.error'))->toBe('Interrupted by a Coolify restart.') + ->and(data_get($activity, 'properties.exitCode'))->toBe(1); + } + + expect(data_get($finishedDatabaseStart->refresh(), 'properties.status'))->toBe(ProcessStatus::FINISHED->value) + ->and(data_get($finishedDatabaseStart, 'properties.error'))->toBeNull() + ->and(data_get($failedDatabaseStart->refresh(), 'properties.status'))->toBe(ProcessStatus::ERROR->value) + ->and(data_get($failedDatabaseStart, 'properties.error'))->toBeNull() + ->and(data_get($unrelatedRunningProcess->refresh(), 'properties.status'))->toBe(ProcessStatus::IN_PROGRESS->value); +}); + +it('runs the interrupted start cleanup from app:init', function () { + $source = file_get_contents(app_path('Console/Commands/Init.php')); + + expect($source)->toContain('ResourceStartActivity::failInterrupted()'); +}); diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php index 29c8814ded..842fc00266 100644 --- a/tests/Feature/Webhook/WebhookHmacTest.php +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -9,16 +9,180 @@ use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; +use Illuminate\Testing\TestResponse; +use Tests\TestCase; uses(RefreshDatabase::class); -test('manual webhook routes are rate limited', function (string $provider) { +test('manual webhook routes are not rate limited per request', function (string $provider) { $route = Route::getRoutes()->match(Request::create("/webhooks/source/{$provider}/events/manual", 'POST')); - expect($route->gatherMiddleware())->toContain('throttle:60,1'); + expect(collect($route->gatherMiddleware())->filter(fn (mixed $middleware): bool => is_string($middleware) && str_starts_with($middleware, 'throttle')))->toBeEmpty(); })->with(['github', 'gitlab', 'bitbucket', 'gitea']); +function sendManualWebhookPush(TestCase $test, string $provider, Application $application, bool $validSignature = true, string $ip = '203.0.113.10', string $repository = 'test-org/test-repo'): TestResponse +{ + $secret = $validSignature ? $application->{"manual_webhook_secret_{$provider}"} : 'wrong-secret'; + $server = ['REMOTE_ADDR' => $ip, 'CONTENT_TYPE' => 'application/json']; + + if ($provider === 'gitlab') { + $payload = json_encode([ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => $repository], + 'after' => 'abc123', + 'commits' => [], + ]); + + return $test->call('POST', '/webhooks/source/gitlab/events/manual', [], [], [], $server + [ + 'HTTP_X-Gitlab-Token' => $secret, + ], $payload); + } + + if ($provider === 'bitbucket') { + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => $repository], + ]); + + return $test->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], $server + [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, $secret), + ], $payload); + } + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => $repository], + 'after' => 'abc123', + 'commits' => [], + ]); + $eventHeader = $provider === 'github' ? 'HTTP_X-GitHub-Event' : 'HTTP_X-Gitea-Event'; + + return $test->call('POST', "/webhooks/source/{$provider}/events/manual", [], [], [], $server + [ + $eventHeader => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret), + ], $payload); +} + +describe('Manual Webhook Failed Authentication Rate Limiting', function () { + test('valid signed deliveries are never throttled', function (string $provider) { + $application = createApplicationWithWebhook(); + + for ($i = 0; $i < 80; $i++) { + $response = sendManualWebhookPush($this, $provider, $application); + + expect($response->getStatusCode())->toBe(200); + expect($response->getContent())->not->toContain('Invalid signature'); + } + + expect(RateLimiter::attempts("manual-webhook-failures:{$provider}:203.0.113.10"))->toBe(0); + })->with(['github', 'gitlab', 'bitbucket', 'gitea']); + + test('repeated invalid signatures are throttled after 30 failures', function (string $provider) { + $application = createApplicationWithWebhook(); + + for ($i = 0; $i < 30; $i++) { + $response = sendManualWebhookPush($this, $provider, $application, validSignature: false); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + } + + $response = sendManualWebhookPush($this, $provider, $application, validSignature: false); + + $response->assertStatus(429); + $response->assertHeader('Retry-After'); + })->with(['github', 'gitlab', 'bitbucket', 'gitea']); + + test('valid deliveries do not count when another matching application has a different secret', function () { + $application = createApplicationWithWebhook(); + createApplicationWithWebhook(overrides: ['name' => 'second-webhook-test-app']); + + for ($i = 0; $i < 35; $i++) { + $response = sendManualWebhookPush($this, 'github', $application); + + $response->assertOk(); + expect($response->getContent())->not->toContain('Invalid signature'); + } + + expect(RateLimiter::attempts('manual-webhook-failures:github:203.0.113.10'))->toBe(0); + }); + + test('deliveries for unknown repositories count as failed authentication', function () { + $application = createApplicationWithWebhook(); + + for ($i = 0; $i < 30; $i++) { + sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo')->assertOk(); + } + + sendManualWebhookPush($this, 'github', $application)->assertStatus(429); + }); + + test('gitlab deliveries without a token count as failed authentication', function () { + createApplicationWithWebhook(); + + for ($i = 0; $i < 30; $i++) { + $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + ])->assertOk(); + } + + expect(RateLimiter::tooManyAttempts('manual-webhook-failures:gitlab:127.0.0.1', 30))->toBeTrue(); + }); + + test('ping deliveries do not count as failures', function () { + $application = createApplicationWithWebhook(); + + for ($i = 0; $i < 40; $i++) { + $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_X-GitHub-Event' => 'ping', + 'CONTENT_TYPE' => 'application/json', + ], '{}')->assertOk(); + } + + $response = sendManualWebhookPush($this, 'github', $application); + + $response->assertOk(); + expect($response->getContent())->not->toContain('Invalid signature'); + }); + + test('failures on one provider do not block another provider', function () { + $application = createApplicationWithWebhook(); + + for ($i = 0; $i < 30; $i++) { + sendManualWebhookPush($this, 'github', $application, validSignature: false); + } + sendManualWebhookPush($this, 'github', $application, validSignature: false)->assertStatus(429); + + foreach (['gitlab', 'bitbucket', 'gitea'] as $provider) { + $response = sendManualWebhookPush($this, $provider, $application); + + $response->assertOk(); + expect($response->getContent())->not->toContain('Invalid signature'); + } + }); + + test('valid deliveries from a different client IP are not blocked', function () { + $application = createApplicationWithWebhook(); + + for ($i = 0; $i < 30; $i++) { + sendManualWebhookPush($this, 'github', $application, validSignature: false, ip: '198.51.100.7'); + } + sendManualWebhookPush($this, 'github', $application, validSignature: false, ip: '198.51.100.7')->assertStatus(429); + + $response = sendManualWebhookPush($this, 'github', $application, ip: '203.0.113.10'); + + $response->assertOk(); + expect($response->getContent())->not->toContain('Invalid signature'); + }); +}); + function createApplicationWithWebhook(string $repo = 'test-org/test-repo', string $branch = 'main', array $overrides = []): Application { $team = Team::factory()->create(); diff --git a/tests/Unit/ComposeBuildPathSecurityTest.php b/tests/Unit/ComposeBuildPathSecurityTest.php index 9b1945115e..c781a195e1 100644 --- a/tests/Unit/ComposeBuildPathSecurityTest.php +++ b/tests/Unit/ComposeBuildPathSecurityTest.php @@ -2,7 +2,7 @@ use App\Jobs\ApplicationDeploymentJob; -function resolveComposeDockerfilePath(string|array $build): string +function resolveComposeDockerfilePath(mixed $build): ?string { $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); $method = new ReflectionMethod(ApplicationDeploymentJob::class, 'resolveComposeDockerfilePath'); @@ -11,31 +11,29 @@ function resolveComposeDockerfilePath(string|array $build): string } test('compose build paths resolve for short and long syntax', function (string|array $build, string $expected) { + // The path is not trusted here: the ARG injection step quotes it and confines the resolved file. expect(resolveComposeDockerfilePath($build))->toBe($expected); })->with([ 'short syntax' => ['services/api', 'services/api/Dockerfile'], - 'current directory short syntax' => ['.', 'Dockerfile'], + 'current directory short syntax' => ['.', './Dockerfile'], 'long syntax defaults' => [['context' => 'services/api'], 'services/api/Dockerfile'], - 'nested Dockerfile' => [['context' => './services/api', 'dockerfile' => 'docker/prod.Dockerfile'], 'services/api/docker/prod.Dockerfile'], - 'standard Dockerfile variants' => [['context' => '.', 'dockerfile' => 'Dockerfile.prod'], 'Dockerfile.prod'], - 'traversal that stays in repository' => [['context' => 'services/api', 'dockerfile' => '../Dockerfile'], 'services/Dockerfile'], + 'nested Dockerfile' => [['context' => './services/api', 'dockerfile' => 'docker/prod.Dockerfile'], './services/api/docker/prod.Dockerfile'], + 'standard Dockerfile variants' => [['context' => '.', 'dockerfile' => 'Dockerfile.prod'], './Dockerfile.prod'], + 'monorepo context above the base directory' => [['context' => '..'], '../Dockerfile'], + 'path with a space' => ['my app', 'my app/Dockerfile'], + 'absolute Dockerfile' => [['context' => '.', 'dockerfile' => '/srv/app/Dockerfile'], '/srv/app/Dockerfile'], ]); -test('compose build paths reject repository escape and shell command injection', function (string|array $build) { - expect(fn () => resolveComposeDockerfilePath($build)) - ->toThrow(RuntimeException::class); +test('compose build contexts that Coolify cannot inspect locally are skipped', function (mixed $build) { + expect(resolveComposeDockerfilePath($build))->toBeNull(); })->with([ - 'short syntax semicolon' => ['.; touch /tmp/short-context-pwned'], - 'short syntax command substitution' => ['$(touch /tmp/short-context-pwned)'], - 'context semicolon' => [['context' => '.; touch /tmp/context-pwned', 'dockerfile' => 'Dockerfile']], - 'dockerfile semicolon' => [['context' => '.', 'dockerfile' => 'Dockerfile; touch /tmp/dockerfile-pwned']], - 'context command substitution' => [['context' => '$(touch /tmp/context-pwned)', 'dockerfile' => 'Dockerfile']], - 'dockerfile command substitution' => [['context' => '.', 'dockerfile' => '$(touch /tmp/dockerfile-pwned)']], - 'context newline' => [['context' => "services/api\ntouch /tmp/context-pwned", 'dockerfile' => 'Dockerfile']], - 'dockerfile newline' => [['context' => '.', 'dockerfile' => "Dockerfile\ntouch /tmp/dockerfile-pwned"]], - 'context traversal' => [['context' => '../outside', 'dockerfile' => 'Dockerfile']], - 'nested traversal' => [['context' => 'services/api', 'dockerfile' => '../../../outside.Dockerfile']], - 'dockerfile traversal' => [['context' => '.', 'dockerfile' => '../outside.Dockerfile']], - 'context absolute path' => [['context' => '/tmp', 'dockerfile' => 'Dockerfile']], - 'dockerfile absolute path' => [['context' => '.', 'dockerfile' => '/tmp/Dockerfile']], + 'git URL' => ['https://github.com/coollabsio/coolify.git#main:docker'], + 'git SSH URL' => [['context' => 'git@github.com:coollabsio/coolify.git']], + 'context variable' => ['${APP_DIR:-.}'], + 'Dockerfile variable' => [['context' => '.', 'dockerfile' => '${DOCKERFILE}']], + 'command substitution' => [['context' => '$(touch /tmp/context-pwned)']], + 'inline Dockerfile' => [['context' => '.', 'dockerfile_inline' => "FROM alpine\n"]], + 'empty context' => [''], + 'non-string context' => [['context' => ['nested']]], + 'invalid build definition' => [42], ]); diff --git a/tests/Unit/InstallScriptTerminalUiTest.php b/tests/Unit/InstallScriptTerminalUiTest.php new file mode 100644 index 0000000000..b5cc1628ff --- /dev/null +++ b/tests/Unit/InstallScriptTerminalUiTest.php @@ -0,0 +1,72 @@ +toContain('TOTAL_STEPS=9') + ->toContain('exec >>"$INSTALLATION_LOG_WITH_DATE" 2>&1') + ->toContain('trap ui_on_exit EXIT') + ->toContain('"true" 3>&-') + ->not->toContain('tee -a $INSTALLATION_LOG_WITH_DATE') + ->not->toContain('getAJoke') + ->and(substr_count($script, 'step_start "'))->toBe(10) + ->and(preg_match_all('/^step_done(?!\()/m', $script))->toBe(9); +})->with([ + 'stable install script' => ['scripts/install.sh'], + 'nightly install script' => ['other/nightly/install.sh'], +]); + +it('renders aligned step lines, warnings and failures without a terminal', function (string $path) { + $demo = installScriptUiHelpers($path).<<<'BASH' + set -e + log() { :; } + log_section() { :; } + INSTALLATION_LOG_WITH_DATE=$(mktemp) + exec 3>&1 + exec >>"$INSTALLATION_LOG_WITH_DATE" 2>&1 + trap ui_on_exit EXIT + step_start "Installing required packages" "curl wget git jq openssl" + step_done + step_start "Pulling images" "coolify · postgres · redis · helper" + warn "SSH PermitRootLogin is disabled." + step_done + step_start "Starting Coolify" "v4.0.0" + echo "ERROR: Coolify container is not healthy" + exit 1 + BASH; + + $process = new Process(['bash', '-c', $demo], env: ['LC_ALL' => 'C', 'TERM' => 'dumb']); + $process->run(); + + $output = $process->getOutput(); + $stepLines = array_values(array_filter(explode("\n", $output), fn (string $line) => str_contains($line, '/9 '))); + + expect($process->getExitCode())->toBe(1) + ->and($output)->not->toContain("\033[") + ->toContain('1/9 Installing required packages curl wget git jq openssl') + ->toContain('2/9 Pulling images coolify · postgres · redis · helper') + ->toContain('! SSH PermitRootLogin is disabled.') + ->toContain('3/9 Starting Coolify v4.0.0') + ->toContain('Installation failed.') + ->toContain('ERROR: Coolify container is not healthy') + ->and($stepLines)->toHaveCount(3) + ->and($stepLines[0])->toEndWith('✓') + ->and($stepLines[1])->toEndWith('✓') + ->and($stepLines[2])->toEndWith('✗') + ->and(array_map(fn (string $line) => mb_strlen($line), $stepLines))->each->toBe(80); +})->with([ + 'stable install script' => ['scripts/install.sh'], + 'nightly install script' => ['other/nightly/install.sh'], +]); diff --git a/tests/Unit/TraefikAcmeServiceTest.php b/tests/Unit/TraefikAcmeServiceTest.php new file mode 100644 index 0000000000..9f2621f8d8 --- /dev/null +++ b/tests/Unit/TraefikAcmeServiceTest.php @@ -0,0 +1,75 @@ + [ + 'Account' => ['Email' => 'admin@example.com'], + 'Certificates' => [ + [ + 'domain' => [ + 'main' => 'example.com', + 'sans' => ['www.example.com', '*.example.com'], + ], + 'certificate' => base64_encode('first-certificate'), + 'key' => base64_encode('first-key'), + 'Store' => 'default', + ], + [ + 'domain' => ['main' => 'api.example.net'], + 'certificate' => base64_encode('second-certificate'), + 'key' => base64_encode('second-key'), + ], + ], + ], + 'zerossl' => [ + 'Account' => ['Email' => 'admin@example.org'], + 'Certificates' => [[ + 'domain' => ['main' => 'example.org', 'sans' => []], + 'certificate' => base64_encode('third-certificate'), + 'key' => base64_encode('third-key'), + ]], + ], + ], JSON_THROW_ON_ERROR); +} + +it('lists every certificate from every ACME resolver without exposing secrets', function () { + $certificates = app(TraefikAcmeService::class)->certificates(traefikAcmeFixture()); + + expect($certificates)->toHaveCount(3) + ->and($certificates[0])->toMatchArray([ + 'resolver' => 'letsencrypt', + 'main_domain' => 'example.com', + 'sans' => ['www.example.com', '*.example.com'], + 'store' => 'default', + ]) + ->and($certificates[0])->toHaveKeys(['id', 'expires_at']) + ->and($certificates[0])->not->toHaveKeys(['certificate', 'key', 'account']); +}); + +it('removes only the selected certificate and preserves all other ACME data', function () { + $service = app(TraefikAcmeService::class); + $certificates = $service->certificates(traefikAcmeFixture()); + + $updated = json_decode($service->deleteCertificate( + traefikAcmeFixture(), + 'letsencrypt', + $certificates[0]['id'], + ), true, flags: JSON_THROW_ON_ERROR); + + expect($updated['letsencrypt']['Account']['Email'])->toBe('admin@example.com') + ->and($updated['letsencrypt']['Certificates'])->toHaveCount(1) + ->and($updated['letsencrypt']['Certificates'][0]['domain']['main'])->toBe('api.example.net') + ->and($updated['zerossl']['Certificates'])->toHaveCount(1); +}); + +it('rejects invalid files and unknown certificate identifiers', function () { + $service = app(TraefikAcmeService::class); + + expect(fn () => $service->certificates('{invalid')) + ->toThrow(RuntimeException::class, 'invalid JSON') + ->and(fn () => $service->deleteCertificate(traefikAcmeFixture(), 'letsencrypt', 'missing')) + ->toThrow(RuntimeException::class, 'could not be found'); +});