From 554bd354bf0064634b495cc24d1cdb5d0bca821f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:08:52 +0200 Subject: [PATCH] fix: support host storage paths and clean up cancelled deployments Allow validated host file and directory mounts outside resource directories while protecting external paths from deletion. Clean up cancelled deployment containers and clear tracked process IDs. Preserve legacy KeyDB and Dragonfly password parsing, accept server backup paths without extension restrictions, and allow Compose variable proxy ports. --- .../CleanupCancelledDeployment.php | 70 ++++ app/Actions/Database/StartDatabaseImport.php | 5 +- app/Actions/Database/StartDragonfly.php | 6 +- app/Actions/Database/StartKeydb.php | 6 +- app/Actions/Proxy/CheckProxy.php | 5 +- .../Api/ApplicationsController.php | 21 +- .../Controllers/Api/DatabasesController.php | 21 +- app/Http/Controllers/Api/DeployController.php | 31 +- .../Api/ScheduledTasksController.php | 12 +- .../Api/ServerSentinelController.php | 6 +- .../Controllers/Api/ServicesController.php | 21 +- app/Http/Controllers/Webhook/Bitbucket.php | 4 - .../ThrottlesManualWebhookFailures.php | 4 +- app/Http/Controllers/Webhook/Gitea.php | 4 - app/Http/Controllers/Webhook/Github.php | 15 +- app/Http/Controllers/Webhook/Gitlab.php | 4 - app/Jobs/ApplicationDeploymentJob.php | 51 ++- app/Jobs/DatabaseBackupJob.php | 95 +++-- app/Jobs/DeregisterGithubRunnerJob.php | 43 +++ app/Jobs/ProvisionGithubRunnerJob.php | 10 + app/Jobs/ReconcileGithubRunnersJob.php | 13 + app/Jobs/ScheduledTaskJob.php | 57 ++- app/Livewire/Analytics.php | 3 + .../Concerns/InteractsWithDnsProviders.php | 14 + app/Livewire/Dashboard/TrafficAnalytics.php | 2 + app/Livewire/Project/Application/Advanced.php | 27 ++ .../Project/Application/Backup/Create.php | 2 +- .../Project/Application/DeploymentNavbar.php | 57 +-- .../Database/CreateScheduledBackup.php | 2 +- .../Database/Sqlite/ConnectApplication.php | 6 +- app/Livewire/Project/Service/FileStorage.php | 42 +- app/Livewire/Project/Service/Storage.php | 30 +- .../Project/Service/VolumeBackup/Create.php | 2 +- .../Shared/ResourceTrafficAnalytics.php | 11 + .../Project/Shared/Storages/VolumeBackups.php | 2 +- app/Livewire/Server/Proxy.php | 9 + .../Server/TrafficAnalyticsSettings.php | 6 +- app/Livewire/SettingsBackup.php | 5 +- app/Mcp/Tools/CancelDeployment.php | 30 +- app/Models/ApplicationDeploymentQueue.php | 17 + app/Models/EnvironmentVariable.php | 46 ++- app/Models/GithubRunnerExecution.php | 20 + app/Models/LocalFileVolume.php | 155 +++++++- app/Models/Server.php | 3 + app/Providers/EventServiceProvider.php | 14 +- app/Services/ProxyPortParser.php | 42 +- app/Services/SentinelTrafficClient.php | 70 +++- app/Support/DatabaseBackupFileValidator.php | 9 +- app/Traits/ExecuteRemoteCommand.php | 8 +- app/Traits/HasSecretManager.php | 19 +- app/Traits/HasSecretManagerAutocomplete.php | 3 +- ...uest_to_github_runner_executions_table.php | 22 ++ ...gacy_enqueued_scheduled_job_deliveries.php | 31 ++ .../project/service/file-storage.blade.php | 10 +- .../project/service/storage.blade.php | 12 +- .../livewire/traffic/_device-chart.blade.php | 3 +- templates/compose/9router.yaml | 6 +- templates/service-templates-latest.json | 2 +- templates/service-templates.json | 2 +- tests/Feature/Api/ScheduledTaskApiTest.php | 57 +++ ...ationDeploymentControlVarFilteringTest.php | 12 + tests/Feature/Auth/LoginFailedAuditTest.php | 42 ++ tests/Feature/BackupRunSafetyTest.php | 46 +++ .../Feature/BuildServerFallbackPolicyTest.php | 51 +++ .../CreateScheduledBackupValidationTest.php | 49 +++ .../DatabaseBackupUploadValidationTest.php | 23 ++ .../DeploymentCancellationCleanupTest.php | 156 ++++++++ .../DeploymentSensitiveCommandFailureTest.php | 89 +++++ tests/Feature/DnsProviderManagementTest.php | 87 +++++ tests/Feature/FileStorageMountPathTest.php | 7 +- tests/Feature/GithubRunnerFlowTest.php | 155 +++++++- ...eydbDragonflyLegacyPasswordQuotingTest.php | 40 ++ .../AdvancedContainerNamingTest.php | 66 ++++ .../Feature/NineRouterServiceTemplateTest.php | 51 +++ .../Feature/Proxy/ProxyPortVariablesTest.php | 26 ++ .../ScheduledJobManagerDispatchTest.php | 87 ++++- .../RemoteSecretResourceLifecycleTest.php | 23 ++ .../SecretManagerLinksComponentTest.php | 30 ++ ...verProxyConfigurationAuthorizationTest.php | 58 +++ .../Feature/SettingsBackupEmptyStateTest.php | 63 +++ .../Feature/SqliteConnectApplicationTest.php | 23 ++ ...artDatabaseImportBackupPreparationTest.php | 51 +++ tests/Feature/StorageAndServiceAuditTest.php | 2 +- tests/Feature/StorageApiTest.php | 14 +- tests/Feature/StorageHostPathTest.php | 365 ++++++++++++++++++ tests/Feature/TraefikVersionStateTest.php | 3 + .../SentinelTrafficAvailabilityTest.php | 94 +++++ .../SentinelTrafficCacheTest.php | 2 + .../SentinelTrafficTokenStdinTest.php | 2 + .../TrafficLockedPropertiesTest.php | 66 ++++ .../TrafficSettingsLimitsTest.php | 74 ++++ tests/Feature/VolumeBackupTest.php | 55 +++ tests/Feature/Webhook/WebhookHmacTest.php | 42 +- tests/Unit/ProxyPortValidationTest.php | 20 +- 94 files changed, 2922 insertions(+), 357 deletions(-) create mode 100644 app/Actions/Application/CleanupCancelledDeployment.php create mode 100644 app/Jobs/DeregisterGithubRunnerJob.php create mode 100644 database/migrations/2026_10_05_064824_add_is_pull_request_to_github_runner_executions_table.php create mode 100644 database/migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php create mode 100644 tests/Feature/Auth/LoginFailedAuditTest.php create mode 100644 tests/Feature/DeploymentCancellationCleanupTest.php create mode 100644 tests/Feature/NineRouterServiceTemplateTest.php create mode 100644 tests/Feature/ServerProxyConfigurationAuthorizationTest.php create mode 100644 tests/Feature/StorageHostPathTest.php create mode 100644 tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php create mode 100644 tests/Feature/TrafficAnalytics/TrafficLockedPropertiesTest.php create mode 100644 tests/Feature/TrafficAnalytics/TrafficSettingsLimitsTest.php diff --git a/app/Actions/Application/CleanupCancelledDeployment.php b/app/Actions/Application/CleanupCancelledDeployment.php new file mode 100644 index 0000000000..171e660e66 --- /dev/null +++ b/app/Actions/Application/CleanupCancelledDeployment.php @@ -0,0 +1,70 @@ +deployment_uuid; + $containerStopped = false; + $firstError = null; + + try { + foreach ($this->helperContainerServers($deployment, $teamId) as $server) { + try { + $containerExists = instant_remote_process(["docker ps -a --filter name={$deploymentUuid} --format '{{.Names}}'"], $server); + + if (str($containerExists)->trim()->isNotEmpty()) { + instant_remote_process(["docker rm -f {$deploymentUuid}"], $server); + $containerStopped = true; + } + } catch (\Throwable $e) { + $firstError ??= $e; + } + } + + if ($containerStopped) { + $deployment->addLogEntry('Deployment container stopped.'); + } elseif (! $firstError) { + $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); + } + } finally { + $deployment->update(['current_process_id' => null]); + } + + if ($firstError) { + throw $firstError; + } + } + + /** + * A restart creates the helper on the deployment server even when a build server was selected. + * + * @return Collection + */ + private function helperContainerServers(ApplicationDeploymentQueue $deployment, int $teamId): Collection + { + $serverIds = collect([$deployment->build_server_id, $deployment->server_id]) + ->reject(fn ($id) => is_null($id)) + ->map(fn ($id) => (int) $id) + ->unique() + ->values(); + + return Server::whereTeamId($teamId) + ->whereKey($serverIds) + ->get() + ->sortBy(fn (Server $server) => $serverIds->search($server->id)) + ->values(); + } +} diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php index 235399e652..5689882c2b 100644 --- a/app/Actions/Database/StartDatabaseImport.php +++ b/app/Actions/Database/StartDatabaseImport.php @@ -219,9 +219,12 @@ class StartDatabaseImport return $file; } + /** + * Server backups can have any file name; the restore script checks the format. + */ private function assertServerPath(?string $path): void { - if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) { + if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path)) { throw new DatabaseImportException('The server path is invalid.'); } } diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index eff973c36d..20f69bb715 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -272,14 +272,14 @@ class StartDragonfly /** * The password argument of the start command. Docker Compose splits the command like a shell, so the * password is quoted. Databases created before this release keep their unquoted v4.3.23 argument when - * quoting would change the password the server receives (backslashes and quotes), so their server - * password stays the same. + * quoting would change it (quotes, backslashes, whitespace, ; & | < >): Compose splits or cuts the old + * command there, so quoting now would change the password or options and can lose data. */ private function requirePassArgument(): string { $keepsUnquotedPassword = $this->database->legacy_password_quoting && ! $this->redisPasswordFromSecretManager - && strpbrk($this->resolvedRedisPassword, '\\\'"') !== false; + && strpbrk($this->resolvedRedisPassword, "\\'\";&|<> \t\r\n") !== false; return $keepsUnquotedPassword ? $this->resolvedRedisPassword : escapeshellarg($this->resolvedRedisPassword); } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 4940f38f58..f6c2ff0bc7 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -318,14 +318,14 @@ class StartKeydb /** * The password argument of the start command. Docker Compose splits the command like a shell, so the * password is quoted. Databases created before this release keep their unquoted v4.3.23 argument when - * quoting would change the password the server receives (backslashes and quotes), so their server - * password stays the same. + * quoting would change it (quotes, backslashes, whitespace, ; & | < >): Compose splits or cuts the old + * command there, so quoting now would change the password or options and can lose data. */ private function requirePassArgument(): string { $keepsUnquotedPassword = $this->database->legacy_password_quoting && ! $this->redisPasswordFromSecretManager - && strpbrk($this->resolvedRedisPassword, '\\\'"') !== false; + && strpbrk($this->resolvedRedisPassword, "\\'\";&|<> \t\r\n") !== false; return $keepsUnquotedPassword ? $this->resolvedRedisPassword : escapeshellarg($this->resolvedRedisPassword); } diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index 6ac4487082..a0419feb31 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -65,11 +65,13 @@ class CheckProxy } $portsToCheck = []; + $publishesOnlyUncheckedPorts = false; try { if ($server->proxyType() !== ProxyTypes::NONE->value) { $proxyCompose = GetProxyConfiguration::run($server); $portsToCheck = ProxyPortParser::fromConfiguration($proxyCompose); + $publishesOnlyUncheckedPorts = $portsToCheck === [] && ProxyPortParser::publishesOnlyUncheckedPorts($proxyCompose); } } catch (\Throwable $e) { Log::error('Error checking proxy: '.$e->getMessage()); @@ -77,7 +79,8 @@ class CheckProxy return false; } if (count($portsToCheck) === 0) { - return false; + // Variable ports such as `${HTTP_PORT}:80` are only resolved by Compose on start. + return $publishesOnlyUncheckedPorts; } $portsToCheck = array_values(array_unique($portsToCheck)); // Check port conflicts in parallel diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ba762aff74..b2d84113f2 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -5243,7 +5243,7 @@ class ApplicationsController extends Controller 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], - 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], + 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'], ], additionalProperties: false, ), @@ -5367,9 +5367,9 @@ class ApplicationsController extends Controller } try { - $fsPath = confinePathToBase(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'storage source path'); + $fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'storage source path'); $mountPath = validateFileMountPath($request->mount_path, 'storage destination path'); - LocalFileVolume::assertRemotePathIsConfined($application->workdir(), $fsPath, $application->destination->server); + LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: true); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -5421,7 +5421,6 @@ class ApplicationsController extends Controller } else { try { $mountPath = validateFileMountPath($request->mount_path, 'file storage path'); - $fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path'); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -5429,6 +5428,20 @@ class ApplicationsController extends Controller ], 422); } + try { + if ($request->filled('fs_path')) { + $fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'file storage source path'); + LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: false); + } else { + $fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path'); + } + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()], + ], 422); + } + $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, 'mount_path' => $mountPath, diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index ed3e18ae17..38c304f3f1 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -4290,7 +4290,7 @@ class DatabasesController extends Controller 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], - 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], + 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'], ], additionalProperties: false, ), @@ -4410,9 +4410,9 @@ class DatabasesController extends Controller } try { - $fsPath = confinePathToBase(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'storage source path'); + $fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'storage source path'); $mountPath = validateFileMountPath($request->mount_path, 'storage destination path'); - LocalFileVolume::assertRemotePathIsConfined($database->workdir(), $fsPath, $database->destination->server); + LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: true); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -4464,7 +4464,6 @@ class DatabasesController extends Controller } else { try { $mountPath = validateFileMountPath($request->mount_path, 'file storage path'); - $fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path'); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -4472,6 +4471,20 @@ class DatabasesController extends Controller ], 422); } + try { + if ($request->filled('fs_path')) { + $fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'file storage source path'); + LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: false); + } else { + $fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path'); + } + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()], + ], 422); + } + $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, 'mount_path' => $mountPath, diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 33bd21da4f..419366d95f 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Application\CleanupCancelledDeployment; use App\Actions\Database\StartDatabase; use App\Actions\Service\StartService; use App\Enums\ApplicationDeploymentStatus; @@ -250,10 +251,6 @@ class DeployController extends Controller $deploymentServer = Server::whereTeamId($teamId)->find($deployment->server_id); try { - $deployment_uuid = $deployment->deployment_uuid; - $kill_command = "docker rm -f {$deployment_uuid}"; - $build_server_id = $deployment->build_server_id ?? $deployment->server_id; - // Mark deployment as cancelled $updated = ApplicationDeploymentQueue::whereKey($deployment->getKey()) ->whereIn('status', $cancellableStatuses) @@ -270,31 +267,9 @@ class DeployController extends Controller $deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value; $cancelled = true; - // Get the server - $server = Server::whereTeamId($teamId)->find($build_server_id); - try { - if ($server) { - // Add cancellation log entry - $deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); - - // Check if container exists and kill it - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process([$kill_command], $server); - $deployment->addLogEntry('Deployment container stopped.'); - } else { - $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); - } - - // Kill running process if process ID exists - if ($deployment->current_process_id) { - $processKillCommand = "kill -9 {$deployment->current_process_id}"; - instant_remote_process([$processKillCommand], $server); - } - } + $deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); + CleanupCancelledDeployment::run($deployment, (int) $teamId); } catch (\Throwable $e) { \Log::warning("Failed to clean up cancelled deployment {$deployment->id}: {$e->getMessage()}"); } diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index 910be871ad..a5c8b4411f 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -70,7 +70,7 @@ class ScheduledTasksController extends Controller 'command' => 'required|string', 'frequency' => 'required|string', 'container' => 'string|nullable', - 'timeout' => 'integer|min:1', + 'timeout' => 'integer|min:1|max:36000', 'enabled' => 'boolean', ]); @@ -146,7 +146,7 @@ class ScheduledTasksController extends Controller 'command' => 'string', 'frequency' => 'string', 'container' => 'string|nullable', - 'timeout' => 'integer|min:1', + 'timeout' => 'integer|min:1|max:36000', 'enabled' => 'boolean', ]); @@ -350,7 +350,7 @@ class ScheduledTasksController extends Controller 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), @@ -437,7 +437,7 @@ class ScheduledTasksController extends Controller 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), @@ -713,7 +713,7 @@ class ScheduledTasksController extends Controller 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), @@ -800,7 +800,7 @@ class ScheduledTasksController extends Controller 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), diff --git a/app/Http/Controllers/Api/ServerSentinelController.php b/app/Http/Controllers/Api/ServerSentinelController.php index 77bc16c1c2..53d90c7617 100644 --- a/app/Http/Controllers/Api/ServerSentinelController.php +++ b/app/Http/Controllers/Api/ServerSentinelController.php @@ -191,10 +191,10 @@ class ServerSentinelController extends Controller 'sentinel_metrics_history_days' => 'integer|min:1', 'sentinel_push_interval_seconds' => 'integer|min:10', 'sentinel_custom_url' => 'nullable|url', - 'traffic_topn' => 'integer|min:1', + 'traffic_topn' => 'integer|min:1|max:1000', 'traffic_sample_threshold' => 'integer|min:0', - 'traffic_retention_1h_days' => 'integer|min:1', - 'traffic_retention_1d_days' => 'integer|min:1', + 'traffic_retention_1h_days' => 'integer|min:1|max:365', + 'traffic_retention_1d_days' => 'integer|min:1|max:3650', 'is_geoip_enabled' => 'boolean', 'geoip_refresh_days' => 'integer|min:1', 'geoip_maxmind_license_key' => 'nullable|string|max:255', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 74872c17ae..7ad3684f43 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -2476,7 +2476,7 @@ class ServicesController extends Controller 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], - 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], + 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'], ], additionalProperties: false, ), @@ -2605,9 +2605,9 @@ class ServicesController extends Controller } try { - $fsPath = confinePathToBase(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'storage source path'); + $fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'storage source path'); $mountPath = validateFileMountPath($request->mount_path, 'storage destination path'); - LocalFileVolume::assertRemotePathIsConfined($service->workdir(), $fsPath, $service->server); + LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: true); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -2659,7 +2659,6 @@ class ServicesController extends Controller } else { try { $mountPath = validateFileMountPath($request->mount_path, 'file storage path'); - $fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path'); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -2667,6 +2666,20 @@ class ServicesController extends Controller ], 422); } + try { + if ($request->filled('fs_path')) { + $fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'file storage source path'); + LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: false); + } else { + $fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path'); + } + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()], + ], 422); + } + $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, 'mount_path' => $mountPath, diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 588ae21633..e06a14e0bf 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -82,10 +82,6 @@ class Bitbucket extends Controller ]); } $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'bitbucket', $full_name, $branch); - if ($this->hasTooManyManualWebhookFailures($failure_key)) { - return $this->tooManyManualWebhookFailuresResponse($failure_key); - } - // A redelivery of the same signed payload is one guess. $failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_bitbucket_token); $applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name); diff --git a/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php b/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php index b3a8421581..7e48d1a637 100644 --- a/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php +++ b/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php @@ -18,9 +18,7 @@ use Illuminate\Support\Facades\RateLimiter; * IPs; one misconfigured repository (wrong secret, deleted application, * untracked branch) therefore cannot lock out deliveries for other repositories. * - * After the failure limit is reached, the scope rejects all deliveries before - * reading application secrets or verifying signatures. Correctly signed - * deliveries in that scope must wait until the failure window expires. + * Valid deliveries are processed while locked: every sender shares the proxy's address. * * In one failure window, only distinct failed attempts are counted. An attempt * is identified by what the secret check depends on: the GitLab token, or the diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 92eddb5759..b3c0b22a48 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -74,10 +74,6 @@ class Gitea extends Controller } $matched_branch = $x_gitea_event === 'pull_request' ? $base_branch : $branch; $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'gitea', $full_name, $matched_branch); - if ($this->hasTooManyManualWebhookFailures($failure_key)) { - return $this->tooManyManualWebhookFailuresResponse($failure_key); - } - // A redelivery of the same signed payload is one guess. $failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_hub_signature_256); $applications = Application::query(); diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 564bb515cd..1dec6b71d9 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -95,10 +95,6 @@ class Github extends Controller default => $base_branch, }; $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'github', $full_name, $matched_branch); - if ($this->hasTooManyManualWebhookFailures($failure_key)) { - return $this->tooManyManualWebhookFailuresResponse($failure_key); - } - // A redelivery of the same signed payload is one guess. $failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_hub_signature_256); $applications = Application::query(); @@ -276,6 +272,11 @@ class Github extends Controller if ($jobId <= 0) { return response('Nothing to do. No workflow job found.'); } + // A public App can be installed by other accounts; only its own installation may use the runners. + $installationId = data_get($payload, 'installation.id'); + if (blank($githubApp->installation_id) || ! is_numeric($installationId) || (int) $installationId !== (int) $githubApp->installation_id) { + return response('Nothing to do. The job belongs to another installation of the GitHub App.'); + } $jobDetails = array_filter([ 'workflow_job_id' => $jobId, @@ -294,13 +295,15 @@ class Github extends Controller if ($matchingConfigs->isEmpty()) { return response('Nothing to do. No runner configuration matches the job labels.'); } - if (! $matchingConfigs->contains('allow_pull_requests', true) && $this->isPullRequestWorkflowJob($githubApp, $payload)) { + $isPullRequest = $matchingConfigs->contains(fn (GithubRunnerConfig $config) => ! $config->allow_pull_requests) + && $this->isPullRequestWorkflowJob($githubApp, $payload); + if ($isPullRequest && ! $matchingConfigs->contains('allow_pull_requests', true)) { return response('Nothing to do. The runner configuration does not allow pull request jobs.'); } $execution = GithubRunnerExecution::createOrFirst( ['github_app_id' => $githubApp->id, 'trigger_workflow_job_id' => $jobId], - [...$jobDetails, 'labels' => $labels, 'status' => GithubRunnerStatus::Queued, 'queued_at' => now()], + [...$jobDetails, 'labels' => $labels, 'is_pull_request' => $isPullRequest, 'status' => GithubRunnerStatus::Queued, 'queued_at' => now()], ); if ($execution->wasRecentlyCreated) { ProvisionGithubRunnerJob::dispatch($execution->id); diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 760e7cc701..c135aef328 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -430,10 +430,6 @@ class Gitlab extends Controller } $matched_branch = $x_gitlab_event === 'merge_request' ? $base_branch : $branch; $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'gitlab', $full_name, $matched_branch); - if ($this->hasTooManyManualWebhookFailures($failure_key)) { - return $this->tooManyManualWebhookFailuresResponse($failure_key); - } - // GitLab sends a static token. A repeated wrong token is one guess. $failure_attempt = $this->manualWebhookTokenAttempt($x_gitlab_token); $applications = Application::query(); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f75e23bfe6..7ffafc33c8 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -155,6 +155,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue /** @var array|null */ private ?array $remote_secrets_cache = null; + private ?bool $has_secret_manager_source = null; + private $env_nixpacks_args; private $env_railpack_args; @@ -466,7 +468,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($mustBuildElsewhere && ! $this->restart_only) { throw new DeploymentException("The deployment server ({$this->server->name}) is set to deployments only, and no usable build server was found. Add a build server or change the server role."); } - if (! $team->is_build_server_fallback_enabled) { + // A restart that has to rebuild selects again in should_skip_build(). + if (! $team->is_build_server_fallback_enabled && ! $this->restart_only) { throw new DeploymentException('No available dedicated build server was found. Enable a usable build server for this team or allow fallback to the deployment server in the team settings.'); } @@ -1484,6 +1487,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } if ($this->restart_only) { $this->restart_only = false; + if (! $this->use_build_server) { + // The restart skipped build server selection. + $this->selectBuildServer(); + if ($this->use_build_server) { + $this->detectBuildKitCapabilities(); + } + } $this->decide_what_to_do(); } @@ -1498,8 +1508,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $environmentVariables ->where('is_buildtime', true) - ->get(['value']) - ->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value)); + ->get(['value', 'is_literal']) + ->contains(fn (EnvironmentVariable $environmentVariable) => $this->uses_remote_secret_references($environmentVariable)); } private function check_image_locally_or_remotely() @@ -1555,6 +1565,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets."); } + $this->application_deployment_queue->redactRemoteSecrets($secrets); $this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()})."); return $this->remote_secrets_cache = $secrets; @@ -1610,16 +1621,38 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { $value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer); + if ($env->is_literal && ! $this->has_secret_manager_source()) { + return $value ?? ''; + } + return $this->substitute_remote_secrets($value ?? '', $env->key); } + /** + * Without a source, a literal keeps "{{vault.KEY}}" as text; other references fail closed. + */ + private function uses_remote_secret_references(EnvironmentVariable $env): bool + { + if (! RemoteSecretReferences::containsReference($env->value)) { + return false; + } + + return ! $env->is_literal || $this->has_secret_manager_source(); + } + + private function has_secret_manager_source(): bool + { + return $this->has_secret_manager_source ??= $this->remote_secrets_cache !== null + || $this->application->secretManagerLink()->exists(); + } + /** * Resolve a runtime variable to its dotenv representation. Values with * secret references are substituted and written as literals. */ private function resolve_environment_variable(EnvironmentVariable $env): ?string { - if (! RemoteSecretReferences::containsReference($env->value)) { + if (! $this->uses_remote_secret_references($env)) { return $env->getResolvedValueWithServer($this->mainServer); } @@ -2055,7 +2088,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } - if (RemoteSecretReferences::containsReference($env->value)) { + if ($this->uses_remote_secret_references($env)) { $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); continue; @@ -2113,7 +2146,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } - if (RemoteSecretReferences::containsReference($env->value)) { + if ($this->uses_remote_secret_references($env)) { $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); continue; @@ -3219,7 +3252,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string { - if (RemoteSecretReferences::containsReference($environmentVariable->value)) { + if ($this->uses_remote_secret_references($environmentVariable)) { $resolved = $this->resolve_environment_variable_raw($environmentVariable); return $resolved === '' ? null : $resolved; @@ -3772,7 +3805,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = RemoteSecretReferences::containsReference($env->value) + $resolvedValue = $this->uses_remote_secret_references($env) ? $this->resolve_environment_variable_raw($env) : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { @@ -3790,7 +3823,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = RemoteSecretReferences::containsReference($env->value) + $resolvedValue = $this->uses_remote_secret_references($env) ? $this->resolve_environment_variable_raw($env) : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 44c9d5c7c7..0cb2b4d9b0 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -32,6 +32,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Spatie\Activitylog\Models\Activity; @@ -41,6 +42,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * Lets a backup that runs too long fail inside handle() before the worker times out. + */ + public const WORKER_TIMEOUT_MARGIN_SECONDS = 120; + public $maxExceptions = 1; public ?Team $team = null; @@ -79,14 +85,20 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public ?S3Storage $s3 = null; - public $timeout = 3600; + public $timeout = 3600 + self::WORKER_TIMEOUT_MARGIN_SECONDS; public ?string $backup_log_uuid = null; + /** + * Queued with the payload, so failed() finds this run's executions after a worker timeout. + */ + public ?string $executionUuidPrefix = null; + public function __construct(public ScheduledDatabaseBackup $backup, public ?string $occurrenceUuid = null) { $this->onQueue(crons_queue()); - $this->timeout = $backup->timeout ?? 3600; + $this->timeout = ($backup->timeout ?? 3600) + self::WORKER_TIMEOUT_MARGIN_SECONDS; + $this->executionUuidPrefix = new_public_id(16); } public function middleware(): array @@ -314,7 +326,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue // Generate unique UUID for each database backup execution $attempts = 0; do { - $this->backup_log_uuid = new_public_id(); + $this->backup_log_uuid = $this->executionUuidPrefix === null ? new_public_id() : $this->executionUuidPrefix.new_public_id(8); $exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists(); $attempts++; if ($attempts >= 3 && $exists) { @@ -547,6 +559,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } + private function commandTimeout(): int + { + return (int) ($this->backup->timeout ?? 3600); + } + private function backupFilenamePart(string $database): string { return preg_replace('/[^A-Za-z0-9_.-]/', '-', $database); @@ -617,7 +634,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } } - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -674,7 +691,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } $commands[] = $backupCommand; - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -701,7 +718,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec {$escapedContainerName} mysqldump -u root -p$escapedPassword $escapedDatabase > {$escapedBackupLocation}"; } - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -728,7 +745,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec {$escapedContainerName} mariadb-dump -u root -p$escapedPassword $escapedDatabase > {$escapedBackupLocation}"; } - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -751,7 +768,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue backupDirectory: $this->backup_dir, ); - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -775,7 +792,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $script = 'f=$(mktemp) && sqlite3 -readonly '.escapeshellarg(StandaloneSqlite::DATA_DIRECTORY.'/'.$database).' \'.timeout 10000\' "VACUUM INTO \'$f\'" && cat "$f"; s=$?; rm -f "$f"; exit $s'; $dumpCommand = 'docker exec '.escapeshellarg($this->container_name).' sh -c '.escapeshellarg($script); $commands[] = $this->buildCompressedDumpCommand($dumpCommand, escapeshellarg($this->backup_location)); - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -865,7 +882,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}"; - instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); + instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->s3_uploaded = true; } catch (Throwable $e) { @@ -1016,29 +1033,32 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'trace' => $exception?->getTraceAsString(), ]); - $log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first(); + // After a worker timeout this runs on a fresh job copy without the state handle() set. + $this->team ??= Team::find($this->backup->team_id); + $executions = $this->executionsOfThisRun(); - if ($log) { - // Don't overwrite a successful backup status — a post-backup error - // (e.g. notification failure) should not retroactively mark the backup - // as failed (see GitHub issue #9088) - if ($log->status !== 'success') { - $log->update([ - 'status' => 'failed', - 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'), - 'size' => 0, - 'filename' => null, - 'finished_at' => Carbon::now(), - ]); - } + // Don't overwrite a finished execution: handle() already reported it, and a post-backup + // error (e.g. notification failure) must not mark a successful backup as failed (#9088). + $unfinished = $executions->whereNotIn('status', ['success', 'failed']); + foreach ($unfinished as $execution) { + $execution->update([ + 'status' => 'failed', + 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'), + 'size' => 0, + 'filename' => null, + 'finished_at' => Carbon::now(), + ]); } - // Notify team about permanent failure (only if backup didn't already succeed) - if ($this->team && $log?->status !== 'success') { - $databaseName = $log?->database_name ?? 'unknown'; - $output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error'; + if ($executions->isNotEmpty() && $unfinished->isEmpty()) { + return; + } + + $output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error'; + $databaseNames = $unfinished->isEmpty() ? ['unknown'] : $unfinished->map(fn (ScheduledDatabaseBackupExecution $execution) => $execution->database_name ?? 'unknown'); + foreach ($databaseNames as $databaseName) { try { - $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName)); + $this->team?->notify(new BackupFailed($this->backup, $this->backup->database, $output, $databaseName)); } catch (Throwable $e) { Log::channel('scheduled-errors')->warning('Failed to send backup permanent failure notification', [ 'backup_id' => $this->backup->uuid, @@ -1047,4 +1067,21 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } } + + /** + * @return Collection + */ + private function executionsOfThisRun(): Collection + { + if ($this->executionUuidPrefix === null && $this->backup_log_uuid === null) { + return collect(); + } + + return ScheduledDatabaseBackupExecution::query() + ->where('scheduled_database_backup_id', $this->backup->id) + ->where(fn ($query) => $query + ->when($this->executionUuidPrefix !== null, fn ($query) => $query->orWhere('uuid', 'like', $this->executionUuidPrefix.'%')) + ->when($this->backup_log_uuid !== null, fn ($query) => $query->orWhere('uuid', $this->backup_log_uuid))) + ->get(); + } } diff --git a/app/Jobs/DeregisterGithubRunnerJob.php b/app/Jobs/DeregisterGithubRunnerJob.php new file mode 100644 index 0000000000..d9c595ee80 --- /dev/null +++ b/app/Jobs/DeregisterGithubRunnerJob.php @@ -0,0 +1,43 @@ +onQueue('high'); + } + + public function handle(): void + { + $githubApp = GithubApp::query()->find($this->githubAppId); + if (! $githubApp) { + return; + } + + try { + (new GithubRunnerApi($githubApp))->deleteRunner($this->runnerId); + } catch (\Throwable $e) { + Log::warning('Could not remove GitHub runner', ['github_app_id' => $this->githubAppId, 'runner_id' => $this->runnerId, 'error' => $e->getMessage()]); + } + } +} diff --git a/app/Jobs/ProvisionGithubRunnerJob.php b/app/Jobs/ProvisionGithubRunnerJob.php index b417635748..f170c34c3b 100644 --- a/app/Jobs/ProvisionGithubRunnerJob.php +++ b/app/Jobs/ProvisionGithubRunnerJob.php @@ -150,6 +150,15 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue private function stopIfFinished(GithubRunnerExecution $execution): bool { + // The execution is deleted together with its server. + if (! GithubRunnerExecution::query()->whereKey($execution->id)->exists()) { + if ($execution->runner_id) { + DeregisterGithubRunnerJob::dispatch($execution->github_app_id, $execution->runner_id); + } + + return true; + } + $execution->refresh(); if ($execution->isActive()) { return false; @@ -181,6 +190,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue ->get() ->filter(fn (GithubRunnerConfig $config) => $config->server !== null && $config->matchesLabels($execution->labels ?? []) + && ($config->allow_pull_requests || ! $execution->is_pull_request) && $config->server->isBuildServer() && ! $config->server->isLocalhost() && $config->server->isFunctional()) diff --git a/app/Jobs/ReconcileGithubRunnersJob.php b/app/Jobs/ReconcileGithubRunnersJob.php index 459dd681c0..61566e9e6e 100644 --- a/app/Jobs/ReconcileGithubRunnersJob.php +++ b/app/Jobs/ReconcileGithubRunnersJob.php @@ -49,9 +49,22 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue ->filter(fn (Server $server) => $server->isFunctional()) ->each(fn (Server $server) => $this->reconcileServer($server)); + $this->removeRunnersOfDeletedServers(); $this->reconcileQueued(); } + /** + * Older versions left occupying executions of a deleted server active, which blocked deleting the App. + */ + private function removeRunnersOfDeletedServers(): void + { + GithubRunnerExecution::deleteAndDeregister( + GithubRunnerExecution::query() + ->whereNull('server_id') + ->whereIn('status', GithubRunnerStatus::occupying()) + ); + } + private function reconcileServer(Server $server): void { $output = instant_remote_process([GithubRunnerContainer::listCommand()], $server, false); diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index e3a352ebb1..8538760db8 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -28,6 +28,11 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024; + /** + * Lets a task that runs too long fail inside handle() before the worker times out. + */ + public const WORKER_TIMEOUT_MARGIN_SECONDS = 120; + /** * The number of times the job may be attempted. */ @@ -41,7 +46,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue /** * The number of seconds the job can run before timing out. */ - public $timeout = 300; + public $timeout = 300 + self::WORKER_TIMEOUT_MARGIN_SECONDS; public ?Team $team = null; @@ -54,9 +59,9 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue public ?ScheduledTaskExecution $task_log = null; /** - * Store execution ID to survive job serialization for timeout handling. + * Queued with the payload, so failed() finds this run's execution after a worker timeout. */ - protected ?int $executionId = null; + public ?string $executionUuid = null; public string $task_status = 'failed'; @@ -71,7 +76,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue $this->onQueue(crons_queue()); $this->task = $task; - $this->timeout = $this->task->timeout ?? 300; + $this->timeout = ($this->task->timeout ?? 300) + self::WORKER_TIMEOUT_MARGIN_SECONDS; + $this->executionUuid = new_public_id(); } private function initializeExecutionContext(): void @@ -126,15 +132,17 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue try { $this->initializeExecutionContext(); - $this->task_log = ScheduledTaskExecution::create([ + // A retried job reuses the queued payload and its execution uuid. + if ($this->executionUuid === null || ScheduledTaskExecution::query()->where('uuid', $this->executionUuid)->exists()) { + $this->executionUuid = new_public_id(); + } + $this->task_log = ScheduledTaskExecution::forceCreate([ + 'uuid' => $this->executionUuid, 'scheduled_task_id' => $this->task->id, 'started_at' => $startTime, 'retry_count' => $this->attempts() - 1, ]); - // Store execution ID for timeout handling - $this->executionId = $this->task_log->id; - if ($this->resource->type() === 'application') { $containers = getCurrentApplicationContainerStatus($this->server, $this->resource, 0); if ($containers->count() > 0) { @@ -170,7 +178,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 - $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true); + $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->task->timeout ?? 300, disableMultiplexing: true); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, @@ -265,30 +273,11 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue 'trace' => $exception?->getTraceAsString(), ]); - // Reload execution log from database - // When a job times out, failed() is called in a fresh process with the original - // queue payload, so $executionId will be null. We need to query for the latest execution. - $execution = null; - - // Try to find execution using stored ID first (works for non-timeout failures) - if ($this->executionId) { - $execution = ScheduledTaskExecution::find($this->executionId); - } - - // If no stored ID or not found, query for the most recent unfinished execution log for this - // task. A finished execution belongs to another run and must keep its result. - if (! $execution) { - $execution = ScheduledTaskExecution::query() - ->where('scheduled_task_id', $this->task->id) - ->whereNull('finished_at') - ->orderBy('created_at', 'desc') - ->first(); - } - - // Last resort: check task_log property - if (! $execution && $this->task_log) { - $execution = $this->task_log; - } + // After a worker timeout only the queued uuid identifies this run's execution. + $execution = $this->executionUuid === null ? null : ScheduledTaskExecution::query() + ->where('uuid', $this->executionUuid) + ->whereNull('finished_at') + ->first(); if ($execution) { $errorMessage = 'Job permanently failed after '.$this->attempts().' attempts'; @@ -304,7 +293,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue ]); } else { Log::channel('scheduled-errors')->warning('Could not find execution log to update', [ - 'execution_id' => $this->executionId, + 'execution_uuid' => $this->executionUuid, 'task_id' => $this->task->uuid, ]); } diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php index 961bd545d6..327d9d220b 100644 --- a/app/Livewire/Analytics.php +++ b/app/Livewire/Analytics.php @@ -13,6 +13,7 @@ use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Livewire\Attributes\Lazy; +use Livewire\Attributes\Locked; use Livewire\Attributes\On; use Livewire\Attributes\Url; use Livewire\Component; @@ -22,8 +23,10 @@ class Analytics extends Component { use BuildsTrafficChartPayload; + #[Locked] public string $chartId = 'global-analytics'; + #[Locked] public ?string $scopedServerUuid = null; /** Traffic-enabled servers owned by the current team. */ diff --git a/app/Livewire/Concerns/InteractsWithDnsProviders.php b/app/Livewire/Concerns/InteractsWithDnsProviders.php index f5e8e103c5..e136bc4d1b 100644 --- a/app/Livewire/Concerns/InteractsWithDnsProviders.php +++ b/app/Livewire/Concerns/InteractsWithDnsProviders.php @@ -241,8 +241,22 @@ trait InteractsWithDnsProviders $this->persistDomainDnsStatuses(); } + /** + * The payload comes from the browser; members without update access also receive it and are ignored. + */ public function dnsRecordConfigurationFinished(array $event): void { + if (! auth()->user()?->can('update', $this->dnsResource())) { + return; + } + + if (! is_string($event['hostname'] ?? null) || ! is_string($event['resourceType'] ?? null) + || ! is_scalar($event['resourceId'] ?? null) || ! is_bool($event['successful'] ?? null) + || ! is_string($event['credential'] ?? null) || ! is_string($event['message'] ?? null) + || ! is_numeric($event['teamId'] ?? null) || (int) $event['teamId'] !== $this->dnsTeamId()) { + return; + } + $resource = $this->dnsResourceForHostname($event['hostname']); if ($resource === null || $resource->getMorphClass() !== $event['resourceType'] || (string) $resource->getKey() !== (string) $event['resourceId']) { diff --git a/app/Livewire/Dashboard/TrafficAnalytics.php b/app/Livewire/Dashboard/TrafficAnalytics.php index 2f67705ca8..047f461d32 100644 --- a/app/Livewire/Dashboard/TrafficAnalytics.php +++ b/app/Livewire/Dashboard/TrafficAnalytics.php @@ -9,6 +9,7 @@ use App\Services\TrafficAnalyticsAggregator; use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; use Livewire\Attributes\Lazy; +use Livewire\Attributes\Locked; use Livewire\Component; #[Lazy] @@ -16,6 +17,7 @@ class TrafficAnalytics extends Component { use BuildsTrafficChartPayload; + #[Locked] public string $chartId = 'dashboard-traffic'; public Collection $servers; diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index da94470872..e7edb958ba 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -15,6 +15,9 @@ class Advanced extends Component { use AuthorizesRequests; + /** The format that saveCustomNamePrefix() produces with str()->slug(). */ + private const CONTAINER_NAME_PREFIX_PATTERN = '/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/'; + public Application $application; #[Validate(['boolean'])] @@ -102,6 +105,7 @@ class Advanced extends Component { if ($toModel) { $this->validate(); + $this->validateChangedContainerNamePrefix(); $this->application->settings->is_force_https_enabled = $this->isForceHttpsEnabled; $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; @@ -159,6 +163,29 @@ class Advanced extends Component $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? ''; } + /** + * An unchanged stored prefix is skipped, so older values do not block other settings. + */ + private function validateChangedContainerNamePrefix(): void + { + $prefix = $this->customContainerNamePrefix; + if (blank($prefix) || $prefix === $this->application->settings->custom_container_name_prefix) { + return; + } + + if (preg_match(self::CONTAINER_NAME_PREFIX_PATTERN, $prefix) !== 1) { + throw ValidationException::withMessages([ + 'customContainerNamePrefix' => 'The container name prefix may only contain lowercase letters, numbers, and single hyphens between them.', + ]); + } + + if (ApplicationSetting::isContainerNamePrefixInUse($prefix, $this->application->destination->server, $this->application->id)) { + throw ValidationException::withMessages([ + 'customContainerNamePrefix' => 'This container name prefix is already in use by another application on this Coolify instance.', + ]); + } + } + private function resetDefaultLabels() { if ($this->application->settings->is_container_label_readonly_enabled === false) { diff --git a/app/Livewire/Project/Application/Backup/Create.php b/app/Livewire/Project/Application/Backup/Create.php index 9b80c8832a..31a2951f4d 100644 --- a/app/Livewire/Project/Application/Backup/Create.php +++ b/app/Livewire/Project/Application/Backup/Create.php @@ -113,7 +113,7 @@ class Create extends Component try { $backup = $target->scheduledBackups()->firstOrNew(); $backup->fill([ - 'team_id' => currentTeam()->id, + 'team_id' => $this->application->environment->project->team_id, 'frequency' => $this->frequency, 'enabled' => true, ]); diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index 05b0165080..2f8592ced2 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -2,13 +2,13 @@ namespace App\Livewire\Project\Application; +use App\Actions\Application\CleanupCancelledDeployment; use App\Enums\ApplicationDeploymentStatus; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Server; use App\Traits\AuditsApplicationSettings; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Carbon; use Livewire\Component; class DeploymentNavbar extends Component @@ -101,69 +101,20 @@ class DeploymentNavbar extends Component } catch (\Throwable $e) { return handleError($e, $this); } - $deployment_uuid = $this->application_deployment_queue->deployment_uuid; - $kill_command = "docker rm -f {$deployment_uuid}"; - $build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id; $server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id; + $server = Server::ownedByCurrentTeam()->find($server_id); // First, mark the deployment as cancelled to prevent further processing $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); try { - if ($this->application->settings->is_build_server_enabled) { - $server = Server::ownedByCurrentTeam()->find($build_server_id); - } else { - $server = Server::ownedByCurrentTeam()->find($server_id); - } - - // Add cancellation log entry - if ($this->application_deployment_queue->logs) { - $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); - - $new_log_entry = [ - 'command' => $kill_command, - 'output' => 'Deployment cancelled by user.', - 'type' => 'stderr', - 'order' => count($previous_logs) + 1, - 'timestamp' => Carbon::now('UTC'), - 'hidden' => false, - ]; - $previous_logs[] = $new_log_entry; - $this->application_deployment_queue->update([ - 'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR), - ]); - } - - // Try to stop the helper container if it exists - // Check if container exists first - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - // Container exists, kill it - instant_remote_process([$kill_command], $server); - } else { - // Container hasn't started yet - $this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.'); - } - - // Also try to kill any running process if we have a process ID - if ($this->application_deployment_queue->current_process_id) { - try { - $processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}"; - instant_remote_process([$processKillCommand], $server); - } catch (\Throwable $e) { - // Process might already be gone, that's ok - } - } + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user.', 'stderr'); + CleanupCancelledDeployment::run($this->application_deployment_queue, currentTeam()->id); } catch (\Throwable $e) { // Still mark as cancelled even if cleanup fails return handleError($e, $this); } finally { - $this->application_deployment_queue->update([ - 'current_process_id' => null, - ]); next_after_cancel($server); } } diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 235eec202e..8c7c49c2e2 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -69,7 +69,7 @@ class CreateScheduledBackup extends Component 's3_storage_id' => null, 'database_id' => $database->id, 'database_type' => $database->getMorphClass(), - 'team_id' => currentTeam()->id, + 'team_id' => $database->team()->id, ]; if ($database->type() === 'standalone-postgresql') { diff --git a/app/Livewire/Project/Database/Sqlite/ConnectApplication.php b/app/Livewire/Project/Database/Sqlite/ConnectApplication.php index b2dadd0d18..c600810c1c 100644 --- a/app/Livewire/Project/Database/Sqlite/ConnectApplication.php +++ b/app/Livewire/Project/Database/Sqlite/ConnectApplication.php @@ -10,6 +10,7 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Computed; +use Livewire\Attributes\Locked; use Livewire\Component; use Symfony\Component\Yaml\Yaml; @@ -25,8 +26,9 @@ class ConnectApplication extends Component public string $mountPath = StandaloneSqlite::DATA_DIRECTORY; /** - * Docker volume that holds the database files. + * Docker volume that holds the database files. Set on the server only: it becomes a mount source. */ + #[Locked] public string $volumeName = ''; /** @@ -73,9 +75,11 @@ class ConnectApplication extends Component $this->validate([ 'applicationUuid' => 'required|string', 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'volumeName' => ValidationPatterns::volumeNameRules(), ], [ 'applicationUuid.required' => 'Select an application first.', 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.', + ...ValidationPatterns::volumeNameMessages('volumeName'), ]); $application = $this->selectedApplication(); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 0c19e73915..87acbda781 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -247,9 +247,9 @@ class FileStorage extends Component } elseif ($this->fileStorage->is_host_file) { $message = 'Host file mount removed.'; } - $deletedFromServer = $this->permanently_delete && ! $this->fileStorage->is_host_file; + $deletedFromServer = $this->canDeleteFromServer() && in_array('permanently_delete', $selectedActions, true); if ($deletedFromServer) { - $message = 'Directory deleted from the server.'; + $message = $this->fileStorage->is_directory ? 'Directory deleted from the server.' : 'File deleted from the server.'; $this->fileStorage->deleteStorageOnServer(); } $this->fileStorage->delete(); @@ -340,18 +340,38 @@ class FileStorage extends Component ], fn ($value) => $value !== null)); } + /** + * Only mounts inside the resource directory are ever deleted on the server. + */ + private function canDeleteFromServer(): bool + { + return ! $this->fileStorage->is_host_file && ! $this->fileStorage->isOutsideResourceDirectory(); + } + public function render() { + $kind = $this->fileStorage->is_directory ? 'directory' : 'file'; + $deletionActions = [ + $this->fileStorage->is_host_file + ? 'The mount will be removed from the container.' + : "The selected {$kind} will be permanently deleted from the container.", + ]; + $deletionCheckboxes = []; + + if ($this->canDeleteFromServer()) { + $deletionCheckboxes[] = [ + 'id' => 'permanently_delete', + 'label' => $this->fileStorage->is_directory + ? 'The selected directory and all its contents will be permanently deleted from the server.' + : 'The selected file will be permanently deleted from the server.', + ]; + } else { + $deletionActions[] = "Only the mount configuration will be removed. The {$kind} at {$this->fileStorage->fs_path} is not deleted on the server."; + } + return view('livewire.project.service.file-storage', [ - 'directoryDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permanently deleted from the server.'], - ], - 'fileDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted from the server.'], - ], - 'hostFileDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'], - ], + 'deletionCheckboxes' => $deletionCheckboxes, + 'deletionActions' => $deletionActions, ]); } } diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 3509fbcad6..b6b92dfe48 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -7,6 +7,7 @@ use App\Livewire\Project\Shared\Storages\All as StorageList; use App\Models\Application; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; +use App\Models\Server; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use App\Support\ValidationPatterns; @@ -35,6 +36,11 @@ class Storage extends Component public string $file_storage_path = ''; + /** + * Optional host path of a new file mount; empty means the resource directory. + */ + public string $file_storage_source = ''; + public ?string $file_storage_content = null; public string $host_file_storage_source = ''; @@ -311,12 +317,18 @@ class Storage extends Component $this->validate([ 'file_storage_path' => 'required|string', + 'file_storage_source' => 'nullable|string', 'file_storage_content' => 'nullable|string', ]); $this->file_storage_path = validateFileMountPath($this->file_storage_path, 'file storage path'); - $fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path'); + if (filled($this->file_storage_source)) { + $fs_path = LocalFileVolume::resolveHostPath($this->fileStorageHostPath(), $this->file_storage_source, 'file storage source path'); + LocalFileVolume::assertHostPathOnServer($this->fileStorageHostPath(), $fs_path, $this->storageServer(), isDirectory: false); + } else { + $fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path'); + } $fileVolume = LocalFileVolume::create([ 'fs_path' => $fs_path, @@ -384,7 +396,7 @@ class Storage extends Component 'file_storage_directory_destination' => 'required|string', ]); - $this->file_storage_directory_source = confinePathToBase( + $this->file_storage_directory_source = LocalFileVolume::resolveHostPath( $this->fileStorageHostPath(), $this->file_storage_directory_source, 'storage source path' @@ -393,8 +405,7 @@ class Storage extends Component $this->file_storage_directory_destination, 'storage destination path' ); - $server = $this->resource->service?->server ?? $this->resource->destination->server; - LocalFileVolume::assertRemotePathIsConfined($this->fileStorageHostPath(), $this->file_storage_directory_source, $server); + LocalFileVolume::assertHostPathOnServer($this->fileStorageHostPath(), $this->file_storage_directory_source, $this->storageServer(), isDirectory: true); $directoryVolume = LocalFileVolume::create([ 'fs_path' => $this->file_storage_directory_source, @@ -421,6 +432,7 @@ class Storage extends Component $this->name = $this->generateDefaultVolumeName(); $this->mount_path = ''; $this->file_storage_path = ''; + $this->file_storage_source = ''; $this->file_storage_content = null; $this->file_storage_directory_destination = ''; $this->host_file_storage_source = ''; @@ -457,8 +469,18 @@ class Storage extends Component return ($name ?: 'volume').'-data'; } + private function storageServer(): Server + { + return $this->resource->service?->server ?? $this->resource->destination->server; + } + public function fileStoragePreviewPath(): string { + $source = str($this->file_storage_source)->trim(); + if ($source->isNotEmpty()) { + return $source->startsWith('/') ? $source->value() : $this->fileStorageHostPath().'/'.preg_replace('#^\./#', '', $source->value()); + } + $path = str($this->file_storage_path)->trim(); if ($path->isEmpty()) { diff --git a/app/Livewire/Project/Service/VolumeBackup/Create.php b/app/Livewire/Project/Service/VolumeBackup/Create.php index 560139f71f..289aec5015 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Create.php +++ b/app/Livewire/Project/Service/VolumeBackup/Create.php @@ -70,7 +70,7 @@ class Create extends Component try { $backup = $target->scheduledBackups()->firstOrNew(); $backup->fill([ - 'team_id' => currentTeam()->id, + 'team_id' => $this->service->environment->project->team_id, 'frequency' => $this->frequency, 'enabled' => true, ]); diff --git a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php index a8a750f17e..4d5e5b9220 100644 --- a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php +++ b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php @@ -10,7 +10,9 @@ use App\Services\TrafficAnalyticsAggregator; use App\Services\TrafficResource; use Illuminate\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; use Livewire\Component; +use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; /** * Traffic analytics tab of one resource (Application or Service). A resource can record @@ -28,6 +30,7 @@ abstract class ResourceTrafficAnalytics extends Component public bool $enabled = false; + #[Locked] public ?string $analyticsServerUuid = null; // Realtime refresh. Off by default (click "Live" to arm it). Only meaningful on the @@ -89,6 +92,14 @@ abstract class ResourceTrafficAnalytics extends Component } } + /** + * Subclasses redeclare $chartId, which drops #[Locked], so the lock is enforced here. + */ + public function updatingChartId(): void + { + throw new CannotUpdateLockedPropertyException('chartId'); + } + public function setRange(string $range): void { $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index 125aaf42bb..9768d67a55 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -421,7 +421,7 @@ class VolumeBackups extends Component { $backup = $this->storage->scheduledBackups()->firstOrNew(); $backup->fill([ - 'team_id' => currentTeam()->id, + 'team_id' => $this->resource->environment->project->team_id, 'frequency' => $this->frequency, 'enabled' => $enabled, 'save_s3' => $this->saveToS3, diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index a7e2858e5b..b1a37f4846 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -220,9 +220,18 @@ class Proxy extends Component } } + /** + * The file can hold secrets; non-editors get no content instead of a 403 because x-init calls this. + */ public function loadProxyConfiguration() { try { + if (! auth()->user()?->can('update', $this->server)) { + $this->proxySettings = null; + + return; + } + $this->proxySettings = GetProxyConfiguration::run($this->server); $this->clearAppliedTraefikBranchWarning(); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php index d2cc5f76eb..6c782cff6e 100644 --- a/app/Livewire/Server/TrafficAnalyticsSettings.php +++ b/app/Livewire/Server/TrafficAnalyticsSettings.php @@ -19,16 +19,16 @@ class TrafficAnalyticsSettings extends Component public bool $isTrafficAnalyticsEnabled; - #[Validate(['required', 'integer', 'min:1'])] + #[Validate(['required', 'integer', 'min:1', 'max:1000'])] public int|string $trafficTopn; #[Validate(['required', 'integer', 'min:0'])] public int|string $trafficSampleThreshold; - #[Validate(['required', 'integer', 'min:1'])] + #[Validate(['required', 'integer', 'min:1', 'max:365'])] public int|string $trafficRetention1hDays; - #[Validate(['required', 'integer', 'min:1'])] + #[Validate(['required', 'integer', 'min:1', 'max:3650'])] public int|string $trafficRetention1dDays; public bool $isGeoipEnabled; diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index 43563e7c83..a7e8b89488 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -53,7 +53,8 @@ class SettingsBackup extends Component } $settings = instanceSettings(); $this->server = Server::findOrFail(0); - $this->database = StandalonePostgresql::whereName('coolify-db')->first(); + // The instance database is always id 0; a team database can also be named coolify-db. + $this->database = StandalonePostgresql::find(0); $s3s = S3Storage::whereTeamId(0)->get() ?? []; if ($this->database) { $this->uuid = $this->database->uuid; @@ -81,7 +82,7 @@ class SettingsBackup extends Component { try { $this->authorize('update', $this->settings); - $this->database = StandalonePostgresql::whereName('coolify-db')->first(); + $this->database = StandalonePostgresql::find(0); if (! $this->database) { $server = Server::findOrFail(0); $out = instant_remote_process(['docker inspect coolify-db'], $server); diff --git a/app/Mcp/Tools/CancelDeployment.php b/app/Mcp/Tools/CancelDeployment.php index 1133f34291..5dcee3ed38 100644 --- a/app/Mcp/Tools/CancelDeployment.php +++ b/app/Mcp/Tools/CancelDeployment.php @@ -2,6 +2,7 @@ namespace App\Mcp\Tools; +use App\Actions\Application\CleanupCancelledDeployment; use App\Enums\ApplicationDeploymentStatus; use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ResolvesTeam; @@ -54,7 +55,6 @@ class CancelDeployment extends Tool ApplicationDeploymentStatus::QUEUED->value, ApplicationDeploymentStatus::IN_PROGRESS->value, ]; - $deploymentUuid = $deployment->deployment_uuid; $updated = ApplicationDeploymentQueue::whereKey($deployment->getKey()) ->whereIn('status', $cancellable) @@ -69,32 +69,10 @@ class CancelDeployment extends Tool $deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value; try { - $buildServerId = $deployment->build_server_id ?? $deployment->server_id; - $server = Server::whereTeamId($teamId)->find($buildServerId); - if ($server) { - $deployment->addLogEntry('Deployment cancelled by user via MCP.', 'stderr'); - - $checkCommand = "docker ps -a --filter name={$deploymentUuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process(["docker rm -f {$deploymentUuid}"], $server); - $deployment->addLogEntry('Deployment container stopped.'); - } else { - $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); - } - - // Parity with REST cancel_deployment: stop the remote build process if known. - if ($deployment->current_process_id) { - try { - instant_remote_process(["kill -9 {$deployment->current_process_id}"], $server); - } catch (\Throwable) { - // Process might already be gone. - } - } - } + $deployment->addLogEntry('Deployment cancelled by user via MCP.', 'stderr'); + CleanupCancelledDeployment::run($deployment, (int) $teamId); } catch (\Throwable) { - // Cancellation is still recorded even if remote kill fails. + // Cancellation is still recorded even if the helper container cleanup fails. } auditLog('mcp.deployment.cancelled', [ diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index bc844d826d..6ebdf255ae 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -46,6 +46,13 @@ use OpenApi\Attributes as OA; )] class ApplicationDeploymentQueue extends Model { + /** + * Kept in memory only, never saved. + * + * @var array + */ + private array $remoteSecretsForRedaction = []; + protected static function booted(): void { static::created(function (ApplicationDeploymentQueue $deployment): void { @@ -179,6 +186,14 @@ class ApplicationDeploymentQueue extends Model return str($this->commit_message)->value(); } + /** + * @param array $secrets + */ + public function redactRemoteSecrets(array $secrets): void + { + $this->remoteSecretsForRedaction = $secrets; + } + private function redactSensitiveInfo($text) { try { @@ -209,6 +224,8 @@ class ApplicationDeploymentQueue extends Model ); } + $lockedVars = $lockedVars->merge(EnvironmentVariable::remoteSecretLogRedactionValues($this->remoteSecretsForRedaction)); + foreach ($lockedVars as $key => $value) { $escapedValue = preg_quote($value, '/'); $text = preg_replace( diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 5d2ccd9e0e..f6a017dbb7 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use App\Support\RemoteSecretValueFormatter; use App\Support\ValidationPatterns; use App\Traits\Auditable; use Illuminate\Database\Eloquent\Builder; @@ -334,17 +335,50 @@ class EnvironmentVariable extends BaseModel $unquoted = str_starts_with($value, "'") && str_ends_with($value, "'") ? substr($value, 1, -1) : $value; - $values[] = $unquoted; - $values[] = escapeBashEnvValue($unquoted); - $values[] = str_replace(["\r\n", "\r", "\n"], ['\\n', '\\n', '\\n'], $unquoted); - if ($this->is_multiline) { - $values = array_merge($values, preg_split('/\r\n|\r|\n/', $unquoted) ?: []); - } + $values = array_merge($values, self::logRedactionVariants($unquoted, (bool) $this->is_multiline)); } return array_values(array_unique(array_filter($values, static fn (string $item): bool => $item !== ''))); } + /** + * @param array $secrets + * @return array + */ + public static function remoteSecretLogRedactionValues(array $secrets): array + { + return collect($secrets) + ->filter(static fn (mixed $secret): bool => is_string($secret) && $secret !== '') + ->flatMap(static fn (string $secret): array => [ + ...self::logRedactionVariants($secret, preg_match('/\r|\n/', $secret) === 1), + RemoteSecretValueFormatter::composeFile($secret), + ]) + ->filter(static fn (string $item): bool => $item !== '') + ->unique() + ->values() + ->all(); + } + + /** + * Also covers the value inside a quoted argument such as 'KEY=value'. + * + * @return array + */ + private static function logRedactionVariants(string $value, bool $splitLines): array + { + $values = [ + $value, + escapeBashEnvValue($value), + str_replace("'", "'\\''", $value), + str_replace(["\r\n", "\r", "\n"], ['\\n', '\\n', '\\n'], $value), + ]; + if ($splitLines) { + $values = array_merge($values, preg_split('/\r\n|\r|\n/', $value) ?: []); + } + + return $values; + } + public function resolveReferencedValue(): ?string { $value = $this->value; diff --git a/app/Models/GithubRunnerExecution.php b/app/Models/GithubRunnerExecution.php index af334209d2..77a55fd075 100644 --- a/app/Models/GithubRunnerExecution.php +++ b/app/Models/GithubRunnerExecution.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Enums\GithubRunnerStatus; +use App\Jobs\DeregisterGithubRunnerJob; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; class GithubRunnerExecution extends BaseModel @@ -19,6 +21,7 @@ class GithubRunnerExecution extends BaseModel 'job_name', 'repository_full_name', 'labels', + 'is_pull_request', 'runner_name', 'runner_id', 'conclusion', @@ -39,6 +42,7 @@ class GithubRunnerExecution extends BaseModel 'runner_id' => 'integer', 'provision_attempts' => 'integer', 'labels' => 'array', + 'is_pull_request' => 'boolean', 'queued_at' => 'datetime', 'ready_at' => 'datetime', 'started_at' => 'datetime', @@ -61,6 +65,22 @@ class GithubRunnerExecution extends BaseModel return $this->belongsTo(Server::class); } + /** + * Deregistering the runners from GitHub is best effort and never blocks the deletion. + * + * @param Builder $query + */ + public static function deleteAndDeregister(Builder $query): void + { + (clone $query) + ->whereIn('status', GithubRunnerStatus::occupying()) + ->whereNotNull('runner_id') + ->get(['github_app_id', 'runner_id']) + ->each(fn (GithubRunnerExecution $execution) => DeregisterGithubRunnerJob::dispatch($execution->github_app_id, $execution->runner_id)); + + (clone $query)->delete(); + } + public function isActive(): bool { return $this->status->isActive(); diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 8e758566e6..a74514c471 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -174,8 +174,11 @@ class LocalFileVolume extends BaseModel $path = $this->resolvedFsPath($workdir); if (! $this->isAdminControlledComposeMount()) { - $path = str(confinePathToBase($workdir, $path->value(), 'storage path')); - $this->assertRemotePathIsConfined($workdir, $path->value(), $server); + [$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory(); + if ($resourceDirectory !== null) { + $this->assertRemotePathIsConfined($resourceDirectory, $hostPath, $server); + } + $path = str($hostPath); } // Validate and escape path to prevent command injection @@ -237,6 +240,7 @@ class LocalFileVolume extends BaseModel /** * Without a server, Coolify deletes the file on every server of the resource. + * Host paths outside the resource directory are never deleted. */ public function deleteStorageOnServer(?Server $server = null) { @@ -253,6 +257,10 @@ class LocalFileVolume extends BaseModel $commands = collect([]); $path = $this->resolvedFsPath($workdir); + if ($this->isOutsideResourceDirectory()) { + return null; + } + if (! $this->isAdminControlledComposeMount()) { $path = str(confinePathToBase($workdir, $path->value(), 'storage path')); $this->assertRemotePathIsConfined($workdir, $path->value(), $server); @@ -279,6 +287,7 @@ class LocalFileVolume extends BaseModel /** * Without a server, Coolify writes the file on every server of the resource. + * Outside the resource directory, nothing is deleted or written through a symlink. */ public function saveStorageOnServer(?Server $server = null) { @@ -299,12 +308,25 @@ class LocalFileVolume extends BaseModel $content = data_get($this, 'content'); $writesContent = $this->writesContentOnServer(); - if ($writesContent) { - $path = str($this->confinedContentPath($path->value(), $server)); - } elseif (! $this->isAdminControlledComposeMount()) { - $path = str(confinePathToBase($workdir, $path->value(), 'storage path')); - $this->assertRemotePathIsConfined($workdir, $path->value(), $server); + if ($this->isAdminControlledComposeMount()) { + // Compose `content:` stays in the resource directory: the Compose file can come from a Git repository. + if ($writesContent) { + $path = str($this->confinedContentPath($path->value(), $server)); + } + } else { + [$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory(); + if ($resourceDirectory === null) { + if (! $this->is_directory) { + self::assertRemotePathIsNotSymlink($hostPath, $server); + } + } elseif ($writesContent) { + $hostPath = $this->confinedContentPath($hostPath, $server); + } else { + $this->assertRemotePathIsConfined($resourceDirectory, $hostPath, $server); + } + $path = str($hostPath); } + $isOutsideResourceDirectory = $this->isOutsideResourceDirectory(); if ($this->is_directory) { validateShellSafePath($path, 'storage path'); @@ -344,8 +366,8 @@ class LocalFileVolume extends BaseModel throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file.

Please delete the directory on the server or mark it as directory.'); } // Docker creates a missing bind source as an empty directory. Replace only an empty - // directory; never delete files that are on the server. - if (self::remoteFileStates([(string) $path], $server)[0] !== 'empty-directory') { + // directory inside the resource directory; never delete files that are on the server. + if ($isOutsideResourceDirectory || self::remoteFileStates([(string) $path], $server)[0] !== 'empty-directory') { throw new \Exception("The following file is a directory on the server, but you are trying to mark it as a file: {$path}

Please delete the directory on the server or mark it as directory."); } $replacesEmptyDirectory = true; @@ -419,9 +441,124 @@ class LocalFileVolume extends BaseModel */ public function contentPathOnServer(): string { + if (! $this->isAdminControlledComposeMount()) { + [$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory(); + if ($resourceDirectory === null) { + return $hostPath; + } + } + return $this->localConfinedContentPath($this->resolvedFsPath($this->ownerResource()->workdir())->value())[1]; } + /** + * `~` is not allowed because it depends on the home directory of the SSH user. + * + * @param string|list $resourceDirectories The first directory resolves relative paths. + * + * @throws \Exception If the path is not allowed + */ + public static function resolveHostPath(string|array $resourceDirectories, string $path, string $context = 'storage path'): string + { + $resourceDirectories = array_map(normalizeUnixPath(...), (array) $resourceDirectories); + $path = trim($path); + if ($path === '') { + throw new \Exception("Invalid {$context}: the path is empty."); + } + if (str_starts_with($path, '~')) { + throw new \Exception("Invalid {$context}: use an absolute path instead of ~."); + } + validateShellSafePath($path, $context); + + $isAbsolute = str_starts_with($path, '/'); + $resolvedPath = normalizeUnixPath($isAbsolute ? $path : $resourceDirectories[0].'/'.$path); + if (self::resourceDirectoryContaining($resolvedPath, $resourceDirectories) !== null) { + return $resolvedPath; + } + + if (! $isAbsolute) { + throw new \Exception("Invalid {$context}: a relative path must stay inside the resource directory."); + } + if (array_intersect(explode('/', $path), ['.', '..']) !== []) { + throw new \Exception("Invalid {$context}: '.' and '..' segments are not allowed."); + } + if ($resolvedPath === '/') { + throw new \Exception("Invalid {$context}: the root directory cannot be mounted."); + } + + return $resolvedPath; + } + + /** + * @throws \RuntimeException If Coolify must not use the path + */ + public static function assertHostPathOnServer(string $resourceDirectory, string $path, Server $server, bool $isDirectory): void + { + if (self::resourceDirectoryContaining($path, [normalizeUnixPath($resourceDirectory)]) !== null) { + self::assertRemotePathIsConfined($resourceDirectory, $path, $server); + } elseif (! $isDirectory) { + self::assertRemotePathIsNotSymlink($path, $server); + } + } + + /** + * @throws \RuntimeException If the path is a symbolic link on the server + */ + public static function assertRemotePathIsNotSymlink(string $path, Server $server): void + { + $escapedPath = escapeshellarg($path); + $result = instant_remote_process(["test -L {$escapedPath} && echo LINK || echo OK"], $server); + + if (trim((string) $result) !== 'OK') { + throw new \RuntimeException("{$path} is a symbolic link on the server. Coolify does not write a file through a symbolic link. Remove the link or use another path."); + } + } + + /** + * A path that cannot be resolved counts as outside. + */ + public function isOutsideResourceDirectory(): bool + { + $directories = $this->contentBaseDirectories(); + try { + $path = normalizeUnixPath($this->resolvedFsPath($directories[0])->value()); + $directories = array_map(normalizeUnixPath(...), $directories); + } catch (\Throwable) { + return true; + } + + return str_starts_with($path, '~') || self::resourceDirectoryContaining($path, $directories) === null; + } + + /** + * The resource directory is null when the host path is outside it. + * + * @return array{0: string, 1: string|null} + * + * @throws \Exception If the path is not allowed + */ + protected function hostPathAndResourceDirectory(): array + { + $directories = $this->contentBaseDirectories(); + $path = self::resolveHostPath($directories, $this->resolvedFsPath($directories[0])->value()); + + return [$path, self::resourceDirectoryContaining($path, array_map(normalizeUnixPath(...), $directories))]; + } + + /** + * @param list $directories Normalized directories + */ + protected static function resourceDirectoryContaining(string $path, array $directories): ?string + { + foreach ($directories as $directory) { + if ($path === $directory || str_starts_with($path, $directory.'/')) { + return $directory; + } + } + + return null; + } + /** * The host path of this mount. Like a relative bind source in Compose, a relative path is inside * the resource directory. Remote commands run in the home directory of the SSH user, so they diff --git a/app/Models/Server.php b/app/Models/Server.php index 88f3ecc15a..16511a2d73 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -236,6 +236,9 @@ class Server extends BaseModel $server->destinations()->each(function ($destination) { $destination->delete(); }); + // Leftover active runner rows would block deleting the GitHub App. + GithubRunnerExecution::deleteAndDeregister(GithubRunnerExecution::query()->where('server_id', $server->id)); + $server->githubRunnerConfig()->delete(); $server->settings()->delete(); $server->sslCertificates()->delete(); $server->notificationThrottles()->delete(); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 7c44c4553e..e9da72d442 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -44,7 +44,7 @@ class EventServiceProvider extends ServiceProvider }); Event::listen(Failed::class, function (Failed $event): void { auditLog('auth.user.login_failed', [ - 'attempted_email' => data_get($event->credentials, 'email'), + 'attempted_email' => $this->attemptedEmailForAudit(data_get($event->credentials, 'email')), 'guard' => $event->guard, ], 'warning'); }); @@ -74,6 +74,18 @@ class EventServiceProvider extends ServiceProvider }); } + /** + * Users sometimes type a password into the email field, so only a valid email address is stored. + */ + private function attemptedEmailForAudit(mixed $email): ?string + { + if (blank($email)) { + return null; + } + + return is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL) !== false ? $email : '[invalid]'; + } + private function authContext(?object $user): array { return [ diff --git a/app/Services/ProxyPortParser.php b/app/Services/ProxyPortParser.php index efc5a551f5..7a1263f476 100644 --- a/app/Services/ProxyPortParser.php +++ b/app/Services/ProxyPortParser.php @@ -37,6 +37,33 @@ class ProxyPortParser * @return list */ public static function fromConfiguration(string $configuration): array + { + $ports = []; + + foreach (self::configuredPorts($configuration) as $configuredPort) { + $publishedPort = self::publishedPort($configuredPort); + if ($publishedPort !== null) { + $ports[] = $publishedPort; + } + } + + return array_values(array_unique($ports)); + } + + /** + * For example, when every port uses a variable without a default. + */ + public static function publishesOnlyUncheckedPorts(string $configuration): bool + { + return self::configuredPorts($configuration) !== [] && self::fromConfiguration($configuration) === []; + } + + /** + * An empty `ports:` key (YAML null) is an empty list, as in Docker Compose. + * + * @return list + */ + private static function configuredPorts(string $configuration): array { try { $parsed = self::withoutTags(Yaml::parse($configuration, Yaml::PARSE_CUSTOM_TAGS)); @@ -51,26 +78,19 @@ class ProxyPortParser $ports = []; foreach (['traefik', 'caddy'] as $proxyService) { - $path = "services.{$proxyService}.ports"; - - if (! data_has($parsed, $path)) { + $configuredPorts = data_get($parsed, "services.{$proxyService}.ports"); + if ($configuredPorts === null) { continue; } - $configuredPorts = data_get($parsed, $path); if (! is_array($configuredPorts) || ! array_is_list($configuredPorts)) { self::invalid(); } - foreach ($configuredPorts as $configuredPort) { - $publishedPort = self::publishedPort($configuredPort); - if ($publishedPort !== null) { - $ports[] = $publishedPort; - } - } + array_push($ports, ...$configuredPorts); } - return array_values(array_unique($ports)); + return $ports; } private static function withoutTags(mixed $value): mixed diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php index b7f4a7733e..9264e3421f 100644 --- a/app/Services/SentinelTrafficClient.php +++ b/app/Services/SentinelTrafficClient.php @@ -6,10 +6,12 @@ use App\Data\Traffic\TrafficBreakdownData; use App\Data\Traffic\TrafficOverviewData; use App\Data\Traffic\TrafficPathData; use App\Data\Traffic\TrafficSeriesBucketData; +use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Process; class SentinelTrafficClient { @@ -24,6 +26,17 @@ class SentinelTrafficClient */ public const RESOURCE_SCOPE_ABSENCE_TTL = 300; + /** + * Matches the "Live" poll interval, so a dead server costs at most one timed-out fetch per minute. + */ + public const UNAVAILABLE_TTL = 60; + + /** Upper bound in seconds of one SSH round-trip (connect, docker exec, every curl). */ + private const REMOTE_TIMEOUT = 20; + + /** Bounds each curl inside the Sentinel container, so a stuck Sentinel cannot hold the SSH session. */ + private const CURL_TIMEOUT_OPTIONS = '--connect-timeout 3 --max-time 10'; + /** Path segment of the per-key routes: `/api/app/{key}/traffic/...`. */ private const SCOPE_APP = 'app'; @@ -749,11 +762,12 @@ class SentinelTrafficClient protected function remoteFetch(string $url): string { + $this->ensureRemoteAvailable(); $token = $this->server->settings->ensureValidSentinelToken(); // Throw the real SSH/docker error (for example, an unreadable SSH key or a missing - // container). A literal "null" body comes back as null; guard() rejects the empty string. - return instant_remote_process([$this->buildFetchCommand($token, $url)], $this->server) ?? ''; + // container). A literal "null" body comes back empty; guard() rejects the empty string. + return $this->runRemote($this->buildFetchCommand($token, $url)); } /** @@ -761,9 +775,55 @@ class SentinelTrafficClient */ protected function batchRemoteFetch(array $urls): string { + $this->ensureRemoteAvailable(); $token = $this->server->settings->ensureValidSentinelToken(); - return instant_remote_process([$this->buildBatchCommand($token, $urls)], $this->server) ?? ''; + return $this->runRemote($this->buildBatchCommand($token, $urls)); + } + + /** + * Page loads and "Live" polls must not wait on a dead server. + */ + private function ensureRemoteAvailable(): void + { + if (Cache::get($this->unavailableKey()) === true) { + throw new \RuntimeException('Traffic analytics is temporarily unavailable on this server.'); + } + + if (! $this->server->isFunctional()) { + throw new \RuntimeException('Server is not reachable.'); + } + } + + /** + * Unlike instant_remote_process(), there are no SSH retries; a failure marks the server unavailable. + */ + private function runRemote(string $command): string + { + $commands = $this->server->isNonRoot() ? parseCommandsByLineForSudo(collect([$command]), $this->server) : [$command]; + + try { + $process = Process::timeout(self::REMOTE_TIMEOUT)->run( + SshMultiplexingHelper::generateSshCommand($this->server, implode("\n", $commands), commandTimeout: self::REMOTE_TIMEOUT) + ); + + if ($process->exitCode() !== 0) { + excludeCertainErrors($process->errorOutput(), $process->exitCode()); + } + } catch (\Throwable $e) { + Cache::put($this->unavailableKey(), true, self::UNAVAILABLE_TTL); + + throw $e; + } + + $output = trim($process->output()); + + return $output === 'null' ? '' : sanitize_utf8_text($output); + } + + private function unavailableKey(): string + { + return 'traffic:unavailable:'.$this->server->uuid; } /** @@ -777,7 +837,7 @@ class SentinelTrafficClient */ protected function buildFetchCommand(string $token, string $url): string { - return "docker exec -i coolify-sentinel curl -sS -H @- \"{$url}\"".$this->authorizationHeredoc($token); + return 'docker exec -i coolify-sentinel curl -sS '.self::CURL_TIMEOUT_OPTIONS." -H @- \"{$url}\"".$this->authorizationHeredoc($token); } /** @@ -791,7 +851,7 @@ class SentinelTrafficClient protected function buildBatchCommand(string $token, array $urls): string { $script = implode("\n", array_map( - fn ($url) => "curl -s -H @- \"{$url}\"".$this->authorizationHeredoc($token)."\nprintf '\\036'", + fn ($url) => 'curl -s '.self::CURL_TIMEOUT_OPTIONS." -H @- \"{$url}\"".$this->authorizationHeredoc($token)."\nprintf '\\036'", $urls )); diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 2c1de948ba..b7052411aa 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -23,6 +23,12 @@ class DatabaseBackupFileValidator 'bz2', 'xz', 'dmp', + 'db', + 'db.gz', + 'sqlite', + 'sqlite.gz', + 'sqlite3', + 'sqlite3.gz', ]; private const DANGEROUS_EXTENSIONS = [ @@ -136,7 +142,7 @@ class DatabaseBackupFileValidator return match ($extension) { 'sql' => self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample), - 'sql.gz', 'gz', 'tar.gz', 'tgz', 'bson.gz', 'archive.gz' => str_starts_with($sample, "\x1f\x8b"), + 'sql.gz', 'gz', 'tar.gz', 'tgz', 'bson.gz', 'archive.gz', 'db.gz', 'sqlite.gz', 'sqlite3.gz' => str_starts_with($sample, "\x1f\x8b"), 'zip' => str_starts_with($sample, "PK\x03\x04") || str_starts_with($sample, "PK\x05\x06") || str_starts_with($sample, "PK\x07\x08"), 'tar' => substr($sample, 257, 5) === 'ustar', 'bz2' => str_starts_with($sample, 'BZh'), @@ -144,6 +150,7 @@ class DatabaseBackupFileValidator 'dump', 'bak', 'archive', 'dmp' => str_starts_with($sample, 'PGDMP') || (self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample)), 'bson' => self::looksLikeBson($path, $sample), + 'db', 'sqlite', 'sqlite3' => str_starts_with($sample, "SQLite format 3\0"), default => false, }; } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index ce3ec8ae3b..71f05e2cf2 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -52,10 +52,7 @@ trait ExecuteRemoteCommand } if (isset($this->remote_secrets_cache)) { - $lockedVars = $lockedVars->merge(array_values(array_filter( - $this->remote_secrets_cache, - static fn (mixed $value): bool => is_string($value) && $value !== '' - ))); + $lockedVars = $lockedVars->merge(EnvironmentVariable::remoteSecretLogRedactionValues($this->remote_secrets_cache)); } foreach ($lockedVars as $key => $value) { @@ -84,6 +81,9 @@ trait ExecuteRemoteCommand if ($this->server instanceof Server === false) { throw new \RuntimeException('Server is not set or is not an instance of Server model'); } + if (isset($this->application_deployment_queue, $this->remote_secrets_cache)) { + $this->application_deployment_queue->redactRemoteSecrets($this->remote_secrets_cache); + } $commandsText->each(function ($single_command) { $command = data_get($single_command, 'command') ?? $single_command[0] ?? null; if ($command === null) { diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php index 4e5d066261..45a1d8eb3d 100644 --- a/app/Traits/HasSecretManager.php +++ b/app/Traits/HasSecretManager.php @@ -136,7 +136,7 @@ trait HasSecretManager return null; } - if (RemoteSecretReferences::containsReference($value)) { + if ($this->resolvesRemoteSecretReferences($environmentVariable, $value)) { $secrets = $this->secretManagerValues(); $missing = RemoteSecretReferences::missingKeys($value, $secrets); @@ -152,11 +152,26 @@ trait HasSecretManager public function environmentVariableUsesSecretManager(EnvironmentVariable $environmentVariable): bool { - return RemoteSecretReferences::containsReference( + return $this->resolvesRemoteSecretReferences( + $environmentVariable, $this->resolvedEnvironmentVariableValue($environmentVariable), ); } + /** + * Without a source, a literal keeps "{{vault.KEY}}" as text; other references fail closed. + */ + private function resolvesRemoteSecretReferences(EnvironmentVariable $environmentVariable, ?string $value): bool + { + if (! RemoteSecretReferences::containsReference($value)) { + return false; + } + + return ! $environmentVariable->is_literal + || $this->resolvedSecretManagerValues !== null + || $this->secretManagerLink()->exists(); + } + private function resolvedEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string { return $environmentVariable->get_real_environment_variables_with_server( diff --git a/app/Traits/HasSecretManagerAutocomplete.php b/app/Traits/HasSecretManagerAutocomplete.php index b210b0be29..afff696a67 100644 --- a/app/Traits/HasSecretManagerAutocomplete.php +++ b/app/Traits/HasSecretManagerAutocomplete.php @@ -38,7 +38,8 @@ trait HasSecretManagerAutocomplete return []; } - $this->authorize('view', $link->resourceable); + // Same ability as SecretManagerLinks::loadKeys(): key names are for users who can manage the source. + $this->authorize('update', $link->resourceable); // Only key names are cached. Another token or other settings use another cache entry. $cacheKey = 'secret-manager-keys:'.$link->id.':'.hash('sha256', json_encode([ diff --git a/database/migrations/2026_10_05_064824_add_is_pull_request_to_github_runner_executions_table.php b/database/migrations/2026_10_05_064824_add_is_pull_request_to_github_runner_executions_table.php new file mode 100644 index 0000000000..97f91e1519 --- /dev/null +++ b/database/migrations/2026_10_05_064824_add_is_pull_request_to_github_runner_executions_table.php @@ -0,0 +1,22 @@ +boolean('is_pull_request')->default(false)->after('labels'); + }); + } + + public function down(): void + { + Schema::table('github_runner_executions', function (Blueprint $table) { + $table->dropColumn('is_pull_request'); + }); + } +}; diff --git a/database/migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php b/database/migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php new file mode 100644 index 0000000000..d18be7e42c --- /dev/null +++ b/database/migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php @@ -0,0 +1,31 @@ +where('status', 'enqueued') + ->where('enqueued_at', '<', now()->subMinutes(self::STALE_AFTER_MINUTES)) + ->update([ + 'status' => 'skipped', + 'updated_at' => now(), + ]); + } + + public function down(): void + { + // + } +}; diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 753b2f6555..6500b0ce28 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -50,8 +50,8 @@
@if ($fileStorage->is_host_file) @@ -72,9 +72,7 @@ @endif @@ -92,7 +90,7 @@ Load from server diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index ab15ccd621..11c510a9a2 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -182,7 +182,12 @@ x-data="{ hostPath: @js($this->fileStorageHostPath()), filePath: @entangle('file_storage_path'), + sourcePath: @entangle('file_storage_source'), previewPath() { + const source = (this.sourcePath || '').trim(); + if (source !== '') { + return source.startsWith('/') ? source : `${this.hostPath}/${source.replace(/^\.\//, '')}`; + } const path = (this.filePath || '').trim(); return this.hostPath + (path === '' ? '/' : (path.startsWith('/') ? path : `/${path}`)); @@ -202,6 +207,11 @@ label="Destination Path" required x-on:input="filePath = $event.target.value" helper="File location inside the container" /> +
@@ -326,7 +336,7 @@ + required helper="Directory on the host: an absolute path anywhere on the host, or a path relative to the resource directory. Coolify creates it when it is missing, and never deletes a directory outside the resource directory." /> ({ position: 'bottom', labels: { colors: textColor } }); const escapeHtml = value => String(value).replace(/[&<>'"]/g, character => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"', })[character]); + // ApexCharts writes legend text with innerHTML; device labels come from request data. + const legend = () => ({ position: 'bottom', labels: { colors: textColor }, formatter: seriesName => escapeHtml(seriesName) }); const chart = new ApexCharts(el, { chart: { type: 'donut', height: 240, background: 'transparent', animations: { enabled: false } }, diff --git a/templates/compose/9router.yaml b/templates/compose/9router.yaml index 31b71a3ddd..ddf87ea687 100644 --- a/templates/compose/9router.yaml +++ b/templates/compose/9router.yaml @@ -13,9 +13,9 @@ services: environment: - SERVICE_URL_9ROUTER_20128 - DATA_DIR=/app/data - - INITIAL_PASSWORD=${SERVICE_PASSWORDWITHSYMBOLS_PASSWORD} - - JWT_SECRET=${SERVICE_PASSWORDWITHSYMBOLS_64_JWTSECRET} - - API_KEY_SECRET=${SERVICE_PASSWORDWITHSYMBOLS_KEYSECRET} + - INITIAL_PASSWORD=${SERVICE_PASSWORD_PASSWORD} + - JWT_SECRET=${SERVICE_PASSWORD_64_JWTSECRET} + - API_KEY_SECRET=${SERVICE_PASSWORD_KEYSECRET} - MACHINE_ID_SALT=${SERVICE_HEX_64_HMACSALT} healthcheck: test: diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 5c172edacc..7e7ba6d322 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2,7 +2,7 @@ "9router": { "documentation": "https://github.com/decolua/9router?utm_source=coolify.io", "slogan": "Free AI coding gateway. Routes Claude Code, Codex, Cursor, Cline, Copilot requests to 40+ LLM providers with auto-fallback and token savings.", - "compose": "c2VydmljZXM6CiAgOXJvdXRlcjoKICAgIGltYWdlOiAnZGVjb2x1YS85cm91dGVyOjAuNS42NScKICAgIHZvbHVtZXM6CiAgICAgIC0gJzlyb3V0ZXI6L2FwcC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfOVJPVVRFUl8yMDEyOAogICAgICAtIERBVEFfRElSPS9hcHAvZGF0YQogICAgICAtICdJTklUSUFMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRFdJVEhTWU1CT0xTX1BBU1NXT1JEfScKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRXSVRIU1lNQk9MU182NF9KV1RTRUNSRVR9JwogICAgICAtICdBUElfS0VZX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRXSVRIU1lNQk9MU19LRVlTRUNSRVR9JwogICAgICAtICdNQUNISU5FX0lEX1NBTFQ9JHtTRVJWSUNFX0hFWF82NF9ITUFDU0FMVH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MjAxMjgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK", + "compose": "c2VydmljZXM6CiAgOXJvdXRlcjoKICAgIGltYWdlOiAnZGVjb2x1YS85cm91dGVyOjAuNS42NScKICAgIHZvbHVtZXM6CiAgICAgIC0gJzlyb3V0ZXI6L2FwcC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfOVJPVVRFUl8yMDEyOAogICAgICAtIERBVEFfRElSPS9hcHAvZGF0YQogICAgICAtICdJTklUSUFMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QQVNTV09SRH0nCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0pXVFNFQ1JFVH0nCiAgICAgIC0gJ0FQSV9LRVlfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9LRVlTRUNSRVR9JwogICAgICAtICdNQUNISU5FX0lEX1NBTFQ9JHtTRVJWSUNFX0hFWF82NF9ITUFDU0FMVH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MjAxMjgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK", "tags": [ "ai", "gateway", diff --git a/templates/service-templates.json b/templates/service-templates.json index 87a6e2d3b4..778dd2597a 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2,7 +2,7 @@ "9router": { "documentation": "https://github.com/decolua/9router?utm_source=coolify.io", "slogan": "Free AI coding gateway. Routes Claude Code, Codex, Cursor, Cline, Copilot requests to 40+ LLM providers with auto-fallback and token savings.", - "compose": "c2VydmljZXM6CiAgOXJvdXRlcjoKICAgIGltYWdlOiAnZGVjb2x1YS85cm91dGVyOjAuNS42NScKICAgIHZvbHVtZXM6CiAgICAgIC0gJzlyb3V0ZXI6L2FwcC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROXzlST1VURVJfMjAxMjgKICAgICAgLSBEQVRBX0RJUj0vYXBwL2RhdGEKICAgICAgLSAnSU5JVElBTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRXSVRIU1lNQk9MU19QQVNTV09SRH0nCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEV0lUSFNZTUJPTFNfNjRfSldUU0VDUkVUfScKICAgICAgLSAnQVBJX0tFWV9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEV0lUSFNZTUJPTFNfS0VZU0VDUkVUfScKICAgICAgLSAnTUFDSElORV9JRF9TQUxUPSR7U0VSVklDRV9IRVhfNjRfSE1BQ1NBTFR9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjIwMTI4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgOXJvdXRlcjoKICAgIGltYWdlOiAnZGVjb2x1YS85cm91dGVyOjAuNS42NScKICAgIHZvbHVtZXM6CiAgICAgIC0gJzlyb3V0ZXI6L2FwcC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROXzlST1VURVJfMjAxMjgKICAgICAgLSBEQVRBX0RJUj0vYXBwL2RhdGEKICAgICAgLSAnSU5JVElBTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUEFTU1dPUkR9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9KV1RTRUNSRVR9JwogICAgICAtICdBUElfS0VZX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZU0VDUkVUfScKICAgICAgLSAnTUFDSElORV9JRF9TQUxUPSR7U0VSVklDRV9IRVhfNjRfSE1BQ1NBTFR9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjIwMTI4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "ai", "gateway", diff --git a/tests/Feature/Api/ScheduledTaskApiTest.php b/tests/Feature/Api/ScheduledTaskApiTest.php index 86066b277f..3c77661ab8 100644 --- a/tests/Feature/Api/ScheduledTaskApiTest.php +++ b/tests/Feature/Api/ScheduledTaskApiTest.php @@ -369,3 +369,60 @@ describe('Service scheduled tasks API', function () { $response->assertJson(['message' => 'Scheduled task deleted.']); }); }); + +describe('scheduled task timeout limit', function () { + test('accepts a timeout of up to 36000 seconds on create and update', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Long task', + 'command' => 'echo test', + 'frequency' => '* * * * *', + 'timeout' => 36000, + ]) + ->assertStatus(201); + $task = ScheduledTask::query()->sole(); + + $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", ['timeout' => 36000]) + ->assertStatus(200); + + expect($task->fresh()->timeout)->toBe(36000); + }); + + test('rejects a timeout above 36000 seconds on create and update', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + 'timeout' => 300, + ]); + + $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Too long task', + 'command' => 'echo test', + 'frequency' => '* * * * *', + 'timeout' => 36001, + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('timeout'); + + $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", ['timeout' => 36001]) + ->assertStatus(422) + ->assertJsonValidationErrors('timeout'); + + expect(ScheduledTask::query()->count())->toBe(1) + ->and($task->fresh()->timeout)->toBe(300); + }); +}); diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index ea527024ad..d9ad7f9f5c 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -181,6 +181,18 @@ it('keeps deployment logging available if value formatting fails', function () { ->toBe(REDACTED); }); +it('redacts shell-escaped remote secrets from command output', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'remote_secrets_cache' => ['DB_PASS' => "s3cr'et-value"], + ]); + + $redacted = invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', "docker build --build-arg DB_PASS='s3cr'\\''et-value' --env 'DB_PASS=s3cr'\\''et-value' ."); + + expect($redacted)->not->toContain('s3cr') + ->toContain('--build-arg DB_PASS='.REDACTED); +}); + it('ignores empty and non-string remote secrets when redacting command output', function () { [$application, $server] = makeDeploymentControlVarFixture(); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ diff --git a/tests/Feature/Auth/LoginFailedAuditTest.php b/tests/Feature/Auth/LoginFailedAuditTest.php new file mode 100644 index 0000000000..f2e7e3d474 --- /dev/null +++ b/tests/Feature/Auth/LoginFailedAuditTest.php @@ -0,0 +1,42 @@ +withoutDefer(); + config()->set('app.maintenance.store', 'array'); + + InstanceSettings::forceCreate(['id' => 0]); + RateLimiter::clear('login'); + Once::flush(); + Log::spy(); +}); + +test('a failed login with a non-email identifier does not store the typed text', function () { + $this->post('/login', [ + 'email' => 'MySecretPassw0rd!', + 'password' => 'wrong-password', + ])->assertRedirect(); + + $failed = AuditEvent::query()->where('event', 'auth.user.login_failed')->sole(); + + expect(json_encode($failed->metadata))->not->toContain('MySecretPassw0rd!') + ->and($failed->metadata['attempted_email'])->toBe('[invalid]'); +}); + +test('a failed login with a valid email address records the attempted email', function () { + $this->post('/login', [ + 'email' => 'person@example.com', + 'password' => 'wrong-password', + ])->assertRedirect(); + + expect(AuditEvent::query()->where('event', 'auth.user.login_failed')->sole()->metadata['attempted_email']) + ->toBe('person@example.com'); +}); diff --git a/tests/Feature/BackupRunSafetyTest.php b/tests/Feature/BackupRunSafetyTest.php index d1fb7480af..0348fc1858 100644 --- a/tests/Feature/BackupRunSafetyTest.php +++ b/tests/Feature/BackupRunSafetyTest.php @@ -20,11 +20,13 @@ use App\Models\Service; use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\Team; +use App\Notifications\Database\BackupFailed; use App\Services\ScheduledJobDeliveryService; use App\Support\DatabaseOperationReservation; use App\Support\ResourceStartActivity; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Queue\TimeoutExceededException; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -275,6 +277,50 @@ it('stores service database backups in the folder of earlier releases', function ->toContain("/rallly-rallly_db-{$service->uuid}/pg-dump-rallly-"); }); +// --- Worker timeout (M16) --- + +it('stops the backup command at the backup timeout, before the worker timeout', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination, ['status' => 'running:healthy']); + $backup = backupRunSafetySchedule($database, $this->team, ['timeout' => 600]); + $commands = backupRunSafetyFakeRemoteCommands(); + $job = new DatabaseBackupJob($backup); + + $job->handle(); + + expect($commands->first(fn (string $command) => str_contains($command, 'pg_dump')))->toStartWith('timeout 600 ssh ') + ->and($job->timeout)->toBeGreaterThan(600); +}); + +it('fails the running backup execution and notifies the team when the worker timeout stops the job', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination, ['status' => 'running:healthy']); + $backup = backupRunSafetySchedule($database, $this->team, ['timeout' => 600]); + $this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'backup_failure_email_notifications' => true]); + $job = new DatabaseBackupJob($backup); + // At the worker timeout, failed() runs on a copy rebuilt from the queued payload. + $queuedCopy = unserialize(serialize($job)); + $team = $this->team; + $atTimeout = null; + Process::fake(function ($process) use ($queuedCopy, $team, &$atTimeout) { + if (str_contains($process->command, 'pg_dump')) { + $queuedCopy->failed(new TimeoutExceededException('App\Jobs\DatabaseBackupJob has timed out.')); + $execution = ScheduledDatabaseBackupExecution::query()->sole(); + $atTimeout = [ + 'status' => $execution->status, + 'finished' => $execution->finished_at !== null, + 'notifications' => Notification::sent($team, BackupFailed::class)->count(), + ]; + + throw new RuntimeException('The worker stopped the job.'); + } + + return Process::result(output: ''); + }); + + $job->handle(); + + expect($atTimeout)->toBe(['status' => 'failed', 'finished' => true, 'notifications' => 1]); +}); + function backupRunSafetyVolume(Environment $environment, StandaloneDocker $destination): LocalPersistentVolume { $application = Application::factory()->create([ diff --git a/tests/Feature/BuildServerFallbackPolicyTest.php b/tests/Feature/BuildServerFallbackPolicyTest.php index a983402447..aee3c54384 100644 --- a/tests/Feature/BuildServerFallbackPolicyTest.php +++ b/tests/Feature/BuildServerFallbackPolicyTest.php @@ -430,3 +430,54 @@ test('additional servers that can build fail instead of building when the regist 'deployments and builds, restart' => ['both', true], 'builds only, deploy' => ['build', false], ]); + +test('strict teams restart on the deployment server when no build server is available', function (string $role, bool $buildServerEnabled) { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = makeRoleServer($team, $role); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, buildServerEnabled: $buildServerEnabled, restartOnly: true); + + $deploymentQueue->shouldNotReceive('setAttribute'); + $deploymentQueue->shouldReceive('addLogEntry')->once()->with('No suitable build server found. Using the deployment server.'); + + invokeBuildServerSelection($job); + + expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue() + ->and(usesRemoteBuildServer($job))->toBeFalse(); +})->with([ + 'deployments-only server' => ['deployment', false], + 'application uses a build server' => ['both', true], +]); + +test('strict teams restart on an available build server', function () { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = makeRoleServer($team, 'deployment'); + $buildServer = makeRoleServer($team, 'build'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, buildServerEnabled: false, restartOnly: true); + + $deploymentQueue->shouldReceive('setAttribute')->with('build_server_id', $buildServer->id)->once()->andReturnSelf(); + $deploymentQueue->shouldReceive('addLogEntry')->once()->with("Found a suitable build server ({$buildServer->name})."); + + invokeBuildServerSelection($job); + + expect(selectedBuildServer($job)->is($buildServer))->toBeTrue() + ->and(usesRemoteBuildServer($job))->toBeTrue(); +}); + +test('a restart that has to rebuild applies the build server rules again', function (string $role, bool $buildServerEnabled, string $message) { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = makeRoleServer($team, $role); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app'], buildServerEnabled: $buildServerEnabled, restartOnly: true); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'is_this_additional_server'))->setValue($job, false); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'saved_outputs'))->setValue($job, collect()); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'pull_request_id'))->setValue($job, 0); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'production_image_name'))->setValue($job, 'ghcr.io/coollabsio/app:abc123'); + + $deploymentQueue->shouldReceive('addLogEntry'); + invokeBuildServerSelection($job); + + expect(fn () => (new ReflectionMethod(ApplicationDeploymentJob::class, 'should_skip_build'))->invoke($job)) + ->toThrow(DeploymentException::class, $message); +})->with([ + 'deployments-only server' => ['deployment', false, 'is set to deployments only, and no usable build server was found.'], + 'application uses a build server' => ['both', true, 'No available dedicated build server was found.'], +]); diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php index 18e6e33d0a..071fdd2035 100644 --- a/tests/Feature/CreateScheduledBackupValidationTest.php +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -261,3 +261,52 @@ it('rejects scheduled backups for unsupported database types', function () { expect(ScheduledDatabaseBackup::count())->toBe(0); }); + +it('assigns a new database backup schedule to the team of the database, not the current team', function (string $databaseType, bool $rootTeam) { + if ($rootTeam) { + $rootTeamModel = Team::factory()->create(['id' => 0]); + $this->user->teams()->attach($rootTeamModel, ['role' => 'owner']); + $this->project->update(['team_id' => 0]); + } + $resourceTeamId = $this->project->fresh()->team_id; + $otherTeam = Team::factory()->create(); + $this->user->teams()->attach($otherTeam, ['role' => 'owner']); + session(['currentTeam' => $otherTeam]); + + if ($databaseType === 'service') { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + $database = ServiceDatabase::create([ + 'service_id' => $service->id, + 'name' => 'postgres', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + } else { + $database = StandalonePostgresql::create([ + 'name' => 'postgres', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + } + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', 'daily') + ->call('submit') + ->assertHasNoErrors(); + + expect(ScheduledDatabaseBackup::query()->sole()->team_id)->toBe($resourceTeamId); +})->with([ + 'standalone database' => ['standalone', false], + 'service database' => ['service', false], + 'standalone database in the root team' => ['standalone', true], +]); diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php index 82833747bc..455fe84610 100644 --- a/tests/Feature/DatabaseBackupUploadValidationTest.php +++ b/tests/Feature/DatabaseBackupUploadValidationTest.php @@ -102,6 +102,12 @@ test('hasAllowedExtension accepts supported extensions', function (string $name) 'archive.gz' => ['data.archive.gz'], 'bz2' => ['data.bz2'], 'xz' => ['data.xz'], + 'sqlite db' => ['app.db'], + 'sqlite' => ['app.sqlite'], + 'sqlite3' => ['app.sqlite3'], + 'gzip sqlite db' => ['app.db.gz'], + 'gzip sqlite' => ['app.sqlite.gz'], + 'gzip sqlite3' => ['app.sqlite3.gz'], ]); test('hasAllowedExtension rejects unsupported or empty stems', function (string $name) { @@ -136,6 +142,23 @@ test('backup validator rejects content that does not match the backup extension' expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse(); }); +test('backup validator accepts SQLite database files and their gzip form', function (string $name, string $content) { + expect(DatabaseBackupFileValidator::isUploadAllowed(makeTemporaryUpload($name, $content), 10 * 1024 * 1024))->toBeTrue(); +})->with([ + 'db' => ['app.db', "SQLite format 3\0".str_repeat("\0", 84)], + 'sqlite' => ['app.sqlite', "SQLite format 3\0".str_repeat("\0", 84)], + 'sqlite3' => ['app.sqlite3', "SQLite format 3\0".str_repeat("\0", 84)], + 'gzip sqlite' => ['app.sqlite.gz', gzencode("SQLite format 3\0".str_repeat("\0", 84))], +]); + +test('backup validator rejects SQLite extensions with content that is not a SQLite database', function (string $name, string $content) { + expect(DatabaseBackupFileValidator::isUploadAllowed(makeTemporaryUpload($name, $content), 10 * 1024 * 1024))->toBeFalse(); +})->with([ + 'sql text as db' => ['app.db', "CREATE TABLE users (id integer);\n"], + 'script as sqlite' => ['app.sqlite', "#!/bin/sh\nid\n"], + 'plain sqlite as sqlite.gz' => ['app.sqlite.gz', "SQLite format 3\0".str_repeat("\0", 84)], +]); + test('backup validator accepts valid plain sql and gzip backup content', function () { $plainSql = makeTemporaryUpload('backup.sql', "CREATE TABLE users (id integer);\n"); $gzipSql = makeTemporaryUpload('backup.sql.gz', gzencode("CREATE TABLE users (id integer);\n")); diff --git a/tests/Feature/DeploymentCancellationCleanupTest.php b/tests/Feature/DeploymentCancellationCleanupTest.php new file mode 100644 index 0000000000..065b0b3057 --- /dev/null +++ b/tests/Feature/DeploymentCancellationCleanupTest.php @@ -0,0 +1,156 @@ + false, + 'cache.default' => 'array', + 'app.maintenance.store' => 'array', + ]); + + $settings = new InstanceSettings(['is_api_enabled' => true, 'is_mcp_server_enabled' => true]); + $settings->id = 0; + $settings->save(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'ip' => '203.0.113.10']); + $this->buildServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'ip' => '203.0.113.20']); + + $destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + // A deployments-only server uses the build server even with this setting off. + $this->application->settings->update(['is_build_server_enabled' => false]); + + $this->deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'cancel-cleanup-uuid', + 'server_id' => $this->server->id, + 'build_server_id' => $this->buildServer->id, + 'destination_id' => $destination->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + 'current_process_id' => '4242', + ]); + + $this->remoteCommands = []; + Process::fake(function (PendingProcess $process) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $this->remoteCommands[] = $command; + + return Process::result(str_contains($command, 'docker ps -a') ? 'cancel-cleanup-uuid' : ''); + }); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function cancelCleanupCommandsOn(array $commands, string $ip, string $needle): array +{ + return array_values(array_filter($commands, fn (string $command) => str_contains($command, "@'{$ip}'") && str_contains($command, $needle))); +} + +function assertCancelCleanupRanCorrectly(array $commands, ApplicationDeploymentQueue $deployment): void +{ + $killCommands = array_filter($commands, fn (string $command) => preg_match('/\bkill\s+-9\b/', $command) === 1 || str_contains($command, 'kill -9 4242')); + + expect($killCommands)->toBe([]) + ->and(cancelCleanupCommandsOn($commands, '203.0.113.20', 'docker rm -f cancel-cleanup-uuid'))->not->toBeEmpty() + ->and($deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value) + ->and($deployment->fresh()->current_process_id)->toBeNull(); +} + +test('cancelling from the UI removes the helper container on the build server and never kills a remote pid', function () { + $this->actingAs($this->user); + + Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]) + ->call('cancel'); + + assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment); +}); + +test('cancelling through the API removes the helper container on the build server and never kills a remote pid', function () { + $token = $this->user->createToken('cancel-cleanup', ['*'])->plainTextToken; + + $this->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson("/api/v1/deployments/{$this->deployment->deployment_uuid}/cancel") + ->assertSuccessful(); + + assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment); +}); + +test('cancelling through MCP removes the helper container on the build server and never kills a remote pid', function () { + $token = $this->user->createToken('cancel-cleanup', ['read', 'deploy'])->plainTextToken; + + $this->withHeaders([ + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => 'cancel_deployment', 'arguments' => ['uuid' => $this->deployment->deployment_uuid]], + ])->assertOk(); + + assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment); +}); + +test('cancelling a deployment without a build server removes the helper container on the deployment server', function () { + $this->deployment->update(['build_server_id' => null]); + $this->actingAs($this->user); + + Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]) + ->call('cancel'); + + expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.10', 'docker rm -f cancel-cleanup-uuid'))->not->toBeEmpty() + ->and(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.20', 'docker'))->toBeEmpty() + ->and(array_filter($this->remoteCommands, fn (string $command) => str_contains($command, 'kill -9')))->toBe([]); +}); + +test('cancelling does not touch servers of another team', function () { + $otherTeam = Team::factory()->create(); + $otherKey = PrivateKey::factory()->create(['team_id' => $otherTeam->id]); + $foreignServer = Server::factory()->create(['team_id' => $otherTeam->id, 'private_key_id' => $otherKey->id, 'ip' => '203.0.113.30']); + $this->deployment->update(['build_server_id' => $foreignServer->id]); + $this->actingAs($this->user); + + Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]) + ->call('cancel'); + + expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.30', 'docker'))->toBeEmpty() + ->and($this->deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value); +}); diff --git a/tests/Feature/DeploymentSensitiveCommandFailureTest.php b/tests/Feature/DeploymentSensitiveCommandFailureTest.php index 2fa3798d2e..3274b76e1b 100644 --- a/tests/Feature/DeploymentSensitiveCommandFailureTest.php +++ b/tests/Feature/DeploymentSensitiveCommandFailureTest.php @@ -6,6 +6,7 @@ use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\IntegrationToken; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; @@ -14,6 +15,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Process\PendingProcess; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; @@ -64,8 +66,11 @@ function makeSensitiveCommandFailureJob(array $environmentVariables = []): array $project = Project::create(['name' => 'Sensitive Failure Project', 'team_id' => $team->id]); $environment = Environment::where('project_id', $project->id)->firstOrFail(); + $destination = $server->standaloneDockers()->firstOrFail(); $application = Application::factory()->create([ 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), 'build_pack' => 'dockerfile', ]); $application->settings()->update([ @@ -447,3 +452,87 @@ function readSensitiveFailureJobProperty(object $job, ReflectionClass $reflectio return $reflectionProperty->getValue($job); } + +it('redacts shell-escaped remote secrets in command logs and later deployment log entries', function () { + [$job, $reflection, $queue] = makeSensitiveCommandFailureJob(); + $remoteSecrets = $reflection->getProperty('remote_secrets_cache'); + $remoteSecrets->setValue($job, ['DB_PASS' => "s3cr'et-value"]); + $escapedSecret = "'s3cr'\\''et-value'"; + + Process::fake(fn () => Process::result(output: "DB_PASS={$escapedSecret}")); + + invokeSensitiveFailureJobMethod($job, $reflection, 'execute_remote_command', ["echo DB_PASS={$escapedSecret}"]); + $queue->addLogEntry("Build argument DB_PASS={$escapedSecret}"); + + $storedLogs = collect(json_decode((string) $queue->fresh()->logs, true)); + expect($storedLogs)->toHaveCount(2) + ->and($storedLogs->toJson())->not->toContain('s3cr') + ->and($storedLogs->pluck('output')->all())->each->toContain('DB_PASS='.REDACTED); +}); + +function linkSensitiveFailureSecretManager(Application $application): void +{ + $token = IntegrationToken::query()->create([ + 'team_id' => $application->team()->id, + 'provider' => 'doppler', + 'name' => 'Doppler', + 'token' => 'the-secret-token', + 'capabilities' => ['secrets'], + ]); + $application->secretManagerLink()->create([ + 'integration_token_id' => $token->id, + 'settings' => ['project' => 'app', 'config' => 'prd'], + ]); +} + +it('redacts fetched remote secrets in log entries the deployment adds before its next remote command', function () { + [$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob(); + linkSensitiveFailureSecretManager($application); + Http::fake(['https://api.doppler.com/*' => Http::response(['DB_PASS' => 'fetched-remote-secret-5521'])]); + + invokeSensitiveFailureJobMethod($job, $reflection, 'remote_secrets'); + $queue->addLogEntry('Resolved DB_PASS=fetched-remote-secret-5521'); + + $storedLogs = (string) $queue->fresh()->logs; + expect($storedLogs)->not->toContain('fetched-remote-secret-5521') + ->toContain('DB_PASS='.REDACTED); +}); + +it('keeps a literal vault reference as text when the application has no secret manager source', function () { + [$job, $reflection, $queue] = makeSensitiveCommandFailureJob([ + ['key' => 'LITERAL_REF', 'value' => '{{vault.FOO}}', 'is_literal' => true], + ]); + Http::fake(); + + $runtime = invokeSensitiveFailureJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + $buildtime = invokeSensitiveFailureJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); + invokeSensitiveFailureJobMethod($job, $reflection, 'generate_env_variables'); + + expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe("LITERAL_REF='{{vault.FOO}}'") + ->and($buildtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe("LITERAL_REF='{{vault.FOO}}'") + ->and(readSensitiveFailureJobProperty($job, $reflection, 'env_args')->get('LITERAL_REF'))->toBe("'{{vault.FOO}}'") + ->and(invokeSensitiveFailureJobMethod($job, $reflection, 'has_remote_buildtime_secret_references'))->toBeFalse(); + Http::assertNothingSent(); +}); + +it('still fails a deployment when a non-literal vault reference has no secret manager source', function () { + [$job, $reflection] = makeSensitiveCommandFailureJob([ + ['key' => 'REMOTE_REF', 'value' => '{{vault.FOO}}', 'is_literal' => false], + ]); + + $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'generate_runtime_environment_variables')); + + expect($exception->getMessage())->toContain('no secret manager source is configured'); +}); + +it('resolves a literal vault reference when the application has a secret manager source', function () { + [$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob([ + ['key' => 'LITERAL_REF', 'value' => '{{vault.FOO}}', 'is_literal' => true], + ]); + linkSensitiveFailureSecretManager($application); + Http::fake(['https://api.doppler.com/*' => Http::response(['FOO' => 'resolved-remote-value'])]); + + $runtime = invokeSensitiveFailureJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe("LITERAL_REF='resolved-remote-value'"); +}); diff --git a/tests/Feature/DnsProviderManagementTest.php b/tests/Feature/DnsProviderManagementTest.php index c12da66b41..cf7f2d5975 100644 --- a/tests/Feature/DnsProviderManagementTest.php +++ b/tests/Feature/DnsProviderManagementTest.php @@ -21,6 +21,7 @@ use App\Models\StandaloneDocker; use App\Models\Team; use App\Models\User; use App\Services\Dns\CloudflareDnsProvider; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; @@ -825,3 +826,89 @@ function prepareManagedDnsApplication(): array return ['application' => $application, 'zone' => $zone]; } + +function dnsRecordFinishedPayload(Model $resource, int $teamId, string $hostname = 'app.example.com'): array +{ + return [ + 'teamId' => $teamId, + 'resourceType' => $resource->getMorphClass(), + 'resourceId' => $resource->getKey(), + 'hostname' => $hostname, + 'successful' => true, + 'credential' => 'Production DNS', + 'message' => "DNS record added for {$hostname}.", + ]; +} + +function actingAsDnsTeamMember(Team $team): void +{ + $member = User::factory()->create(); + $team->members()->attach($member->id, ['role' => 'member']); + test()->actingAs($member); + session(['currentTeam' => $team]); +} + +test('a dns record completion event marks the domain managed for a team admin', function () { + ['application' => $application] = prepareManagedDnsApplication(); + $teamId = $application->team()->id; + + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->call('dnsRecordConfigurationFinished', dnsRecordFinishedPayload($application, $teamId)) + ->assertDispatched('success', 'DNS record added for app.example.com.'); + + expect(collect($application->fresh()->domain_dns_statuses)->pluck('status')->all())->toContain('ok'); +}); + +test('a team member cannot mark a domain managed with a forged dns completion event', function () { + ['application' => $application] = prepareManagedDnsApplication(); + $teamId = $application->team()->id; + $before = $application->fresh()->domain_dns_statuses; + actingAsDnsTeamMember($application->team()); + + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->call('dnsRecordConfigurationFinished', dnsRecordFinishedPayload($application, $teamId)) + ->assertNotDispatched('success'); + + expect($application->fresh()->domain_dns_statuses)->toBe($before); +}); + +test('a dns completion event of another team or a malformed event is ignored', function (string $case) { + ['application' => $application] = prepareManagedDnsApplication(); + $before = $application->fresh()->domain_dns_statuses; + $payload = $case === 'other team' + ? dnsRecordFinishedPayload($application, Team::factory()->create()->id) + : ['hostname' => 'app.example.com', 'successful' => true]; + + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->call('dnsRecordConfigurationFinished', $payload) + ->assertNotDispatched('success'); + + expect($application->fresh()->domain_dns_statuses)->toBe($before); +})->with(['other team', 'missing fields']); + +test('a team member cannot mark a service domain managed with a forged dns completion event', function () { + ['application' => $application] = prepareManagedDnsApplication(); + $team = $application->team(); + $service = Service::factory()->create([ + 'server_id' => $application->destination->server->id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + 'environment_id' => $application->environment_id, + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + $webApp = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'service_id' => $service->id, + 'name' => 'web', + 'human_name' => 'Web', + 'image' => 'nginx:alpine', + 'fqdn' => 'https://web.example.com', + ]); + actingAsDnsTeamMember($team); + + Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])]) + ->call('dnsRecordConfigurationFinished', dnsRecordFinishedPayload($webApp, $team->id, 'web.example.com')) + ->assertNotDispatched('success'); + + expect($webApp->fresh()->domain_dns_statuses)->toBeEmpty(); +}); diff --git a/tests/Feature/FileStorageMountPathTest.php b/tests/Feature/FileStorageMountPathTest.php index fb83da1990..920c708ef0 100644 --- a/tests/Feature/FileStorageMountPathTest.php +++ b/tests/Feature/FileStorageMountPathTest.php @@ -87,15 +87,14 @@ test('livewire file storage rejects parent segments and does not create a local expect(LocalFileVolume::query()->count())->toBe(0); }); -test('livewire directory storage rejects a server absolute path and has no side effects', function () { +test('livewire directory storage accepts a server absolute path for an administrator', function () { Livewire::test(Storage::class, ['resource' => $this->application]) ->set('file_storage_directory_source', '/root/.ssh') ->set('file_storage_directory_destination', '/data') ->call('submitFileStorageDirectory') - ->assertDispatched('error'); + ->assertDispatched('success'); - expect(LocalFileVolume::query()->count())->toBe(0); - Bus::assertNotDispatched(ServerStorageSaveJob::class); + expect(LocalFileVolume::query()->sole()->fs_path)->toBe('/root/.ssh'); }); test('livewire directory storage accepts a path inside the application root', function () { diff --git a/tests/Feature/GithubRunnerFlowTest.php b/tests/Feature/GithubRunnerFlowTest.php index fe28fc2f55..c452f3b7bd 100644 --- a/tests/Feature/GithubRunnerFlowTest.php +++ b/tests/Feature/GithubRunnerFlowTest.php @@ -1,7 +1,9 @@ $action, @@ -111,6 +113,7 @@ function sendWorkflowJobWebhook($test, GithubApp $githubApp, string $secret, str ], 'repository' => ['id' => 99, 'full_name' => 'acme/api'], 'organization' => ['login' => 'acme'], + 'installation' => ['id' => $installationId ?? $githubApp->installation_id], ], JSON_THROW_ON_ERROR); return $test->call('POST', '/webhooks/source/github/events', [], [], [], [ @@ -211,6 +214,38 @@ describe('workflow_job webhook', function () { Http::assertNothingSent(); }); + it('ignores jobs from another installation of the GitHub App', function (string $action) { + $execution = runnerTestExecution($this->githubApp, [ + 'trigger_workflow_job_id' => 5001, + 'status' => GithubRunnerStatus::Running, + 'server_id' => $this->server->id, + 'runner_name' => 'coolify-runner-abc', + ]); + + sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', $action, [ + 'id' => 6001, + 'runner_name' => 'coolify-runner-abc', + 'conclusion' => 'success', + ], installationId: 987654) + ->assertOk() + ->assertSee('another installation'); + + expect(GithubRunnerExecution::count())->toBe(1) + ->and($execution->fresh()->status)->toBe(GithubRunnerStatus::Running); + Queue::assertNothingPushed(); + })->with(['queued', 'completed']); + + it('marks a pull request job when only some matching configurations allow pull request jobs', function () { + Http::preventStrayRequests(); + fakeWorkflowRunApi($this->githubApp, Http::response(['id' => 321, 'event' => 'pull_request'])); + runnerTestConfig(runnerTestServer($this->team), $this->githubApp, ['allow_pull_requests' => true]); + + sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', ['run_id' => 321])->assertOk(); + + expect(GithubRunnerExecution::sole()->is_pull_request)->toBeTrue(); + Queue::assertPushed(ProvisionGithubRunnerJob::class, 1); + }); + it('rejects an invalid signature', function () { sendWorkflowJobWebhook($this, $this->githubApp, 'wrong-secret', 'queued', [])->assertSee('Invalid signature'); @@ -366,6 +401,58 @@ describe('provisioning', function () { expect($execution->fresh()->server_id)->toBe($server->id); }); + it('places a pull request job only on a server whose configuration allows pull request jobs', function () { + Queue::fake(); + fakeRunnerGithubApi($this->githubApp); + Process::fake(['*' => Process::result(output: '29.8.0')]); + $refusingServer = runnerTestServer($this->team); + runnerTestConfig($refusingServer, $this->githubApp, ['allow_pull_requests' => false]); + $allowingServer = runnerTestServer($this->team); + $allowingConfig = runnerTestConfig($allowingServer, $this->githubApp, ['allow_pull_requests' => true]); + runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $allowingServer->id, 'github_runner_config_id' => $allowingConfig->id]); + $execution = runnerTestExecution($this->githubApp, ['is_pull_request' => true]); + + (new ProvisionGithubRunnerJob($execution->id))->handle(); + + expect($execution->fresh()->server_id)->toBe($allowingServer->id); + }); + + it('keeps a pull request job queued when no configuration with capacity allows pull request jobs', function () { + Queue::fake(); + Process::fake(); + runnerTestConfig(runnerTestServer($this->team), $this->githubApp, ['allow_pull_requests' => false]); + $execution = runnerTestExecution($this->githubApp, ['is_pull_request' => true]); + + (new ProvisionGithubRunnerJob($execution->id))->handle(); + + expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued); + Process::assertNothingRan(); + }); + + it('stops provisioning when the server is deleted while GitHub registers the runner', function () { + Queue::fake(); + Process::fake(['*' => Process::result(output: '29.8.0')]); + $server = runnerTestServer($this->team); + runnerTestConfig($server, $this->githubApp); + $execution = runnerTestExecution($this->githubApp); + Http::fake([ + 'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201), + 'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => function () use ($server) { + DeleteServer::run($server->id); + + return Http::response(['runner' => ['id' => 42], 'encoded_jit_config' => 'SECRET-JIT-CONFIG'], 201); + }, + ]); + + (new ProvisionGithubRunnerJob($execution->id))->handle(); + + expect(GithubRunnerExecution::find($execution->id))->toBeNull(); + Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, '/home/runner/run.sh')); + Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->githubAppId === $this->githubApp->id && $job->runnerId === 42); + Queue::assertNotPushed(ProvisionGithubRunnerJob::class); + }); + it('does not start runners on the Coolify host', function () { Queue::fake(); Process::fake(); @@ -624,6 +711,72 @@ describe('cleanup and reconciliation', function () { }); }); +describe('server deletion', function () { + beforeEach(function () { + $this->server = runnerTestServer($this->team); + $this->config = runnerTestConfig($this->server, $this->githubApp); + }); + + it('deletes the runner configuration and executions of the server and lets the GitHub App be deleted', function () { + Queue::fake(); + $running = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'runner_id' => 42]); + $provisioning = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Provisioning, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id]); + $completed = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $this->server->id, 'runner_id' => 41]); + $otherServer = runnerTestServer($this->team); + $otherConfig = runnerTestConfig($otherServer, $this->githubApp, ['is_enabled' => false]); + $other = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $otherServer->id]); + $queued = runnerTestExecution($this->githubApp); + + $this->server->delete(); + DeleteServer::run($this->server->id); + + expect(Server::withTrashed()->find($this->server->id))->toBeNull() + ->and(GithubRunnerConfig::find($this->config->id))->toBeNull() + ->and(GithubRunnerExecution::whereKey([$running->id, $provisioning->id, $completed->id])->exists())->toBeFalse() + ->and($other->fresh())->not->toBeNull() + ->and($otherConfig->fresh())->not->toBeNull() + ->and($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued); + Queue::assertPushed(DeregisterGithubRunnerJob::class, 1); + Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->githubAppId === $this->githubApp->id && $job->runnerId === 42); + + $this->githubApp->delete(); + expect(GithubApp::find($this->githubApp->id))->toBeNull(); + }); + + it('removes the runner from GitHub and ignores GitHub errors', function (int $status) { + Http::fake([ + 'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201), + 'https://api.github.com/orgs/acme/actions/runners/42' => Http::response(null, $status), + ]); + + (new DeregisterGithubRunnerJob($this->githubApp->id, 42))->handle(); + + Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_ends_with($request->url(), '/orgs/acme/actions/runners/42')); + })->with([204, 500]); + + it('removes runners of servers deleted by older versions during reconciliation', function () { + Queue::fake(); + Process::fake(['*' => Process::result(output: '')]); + $orphan = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'runner_id' => 42]); + $orphanWithoutRunner = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Provisioning]); + $queued = runnerTestExecution($this->githubApp); + $finished = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::TimedOut]); + + (new ReconcileGithubRunnersJob)->handle(); + + expect(GithubRunnerExecution::whereKey([$orphan->id, $orphanWithoutRunner->id])->exists())->toBeFalse() + ->and($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued) + ->and($finished->fresh()->status)->toBe(GithubRunnerStatus::TimedOut); + Queue::assertPushed(DeregisterGithubRunnerJob::class, 1); + Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->runnerId === 42); + + $this->config->update(['is_enabled' => false]); + $this->githubApp->delete(); + expect(GithubApp::find($this->githubApp->id))->toBeNull(); + }); +}); + function fakeRejectedJitConfig(): void { Http::fake([ diff --git a/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php b/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php index 6f0e25f794..823017c0a8 100644 --- a/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php +++ b/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php @@ -155,3 +155,43 @@ it('marks only databases that exist before the migration as legacy', function () ->and($newKeydb->fresh()->legacy_password_quoting)->toBeFalse() ->and($newDragonfly->fresh()->legacy_password_quoting)->toBeFalse(); }); + +it('keeps the exact v4.3.23 start command when Docker Compose split or cut the unquoted legacy password', function (string $create, string $start, string $passwordColumn, string $commandFormat, string $password) { + $database = $create($this->environment->id, $this->destination, [$passwordColumn => $password]); + DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]); + + $start::run($database->fresh(), new Activity); + + expect(legacyPasswordQuotingStartedService($this->executor, $database->uuid)['command']) + ->toBe(sprintf($commandFormat, $password)); +})->with('legacy-password-quoting-engines')->with([ + 'semicolon' => 'ab;cd', + 'space' => 'a b', + 'tab' => "a\tb", + 'pipe' => 'x|y', + 'ampersand' => 'p&q', + 'redirects' => 'a', +]); + +it('quotes a plain legacy password, which gives Docker Compose the same argument as v4.3.23', function (string $create, string $start, string $passwordColumn, string $commandFormat) { + $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'abc']); + DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]); + + $start::run($database->fresh(), new Activity); + + expect(legacyPasswordQuotingStartedService($this->executor, $database->uuid)['command']) + ->toBe(sprintf($commandFormat, "'abc'")); +})->with('legacy-password-quoting-engines'); + +it('quotes shell control characters in the password of a database created after the upgrade', function (string $create, string $start, string $passwordColumn, string $commandFormat, string $password) { + $database = $create($this->environment->id, $this->destination, [$passwordColumn => $password]); + + $start::run($database->fresh(), new Activity); + + expect(legacyPasswordQuotingStartedService($this->executor, $database->uuid)['command']) + ->toBe(sprintf($commandFormat, escapeshellarg($password))); +})->with('legacy-password-quoting-engines')->with([ + 'semicolon' => 'ab;cd', + 'space' => 'a b', + 'pipe' => 'x|y', +]); diff --git a/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php b/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php index 97659c3210..e410aeb799 100644 --- a/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php +++ b/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php @@ -182,3 +182,69 @@ it('saves a unique custom container name', function () { expect($application->settings()->first()->custom_internal_name)->toBe('unique-name'); }); + +it('rejects an invalid container name prefix on every save path', function (string $method, string $prefix) { + $application = createApplicationForContainerNamingTest(); + $application->settings->update(['custom_container_name_prefix' => 'original-prefix']); + $application = $application->fresh(['environment.project', 'settings', 'destination']); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('customContainerNamePrefix', $prefix) + ->call($method) + ->assertDispatched('error') + ->assertNotDispatched('success'); + + expect($application->settings()->first()->custom_container_name_prefix)->toBe('original-prefix'); +})->with([ + 'submit' => ['submit'], + 'instant save' => ['instantSave'], +])->with([ + 'shell characters' => ['Bad Name;id'], + 'uppercase' => ['MyApp'], + 'leading hyphen' => ['-api'], +]); + +it('rejects a container name prefix already used on the server on every save path', function (string $method) { + $application = createApplicationForContainerNamingTest(); + Application::factory()->create([ + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ])->settings->update(['custom_container_name_prefix' => 'shared-prefix']); + $application = $application->fresh(['environment.project', 'settings', 'destination']); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('customContainerNamePrefix', 'shared-prefix') + ->call($method) + ->assertDispatched('error', 'This container name prefix is already in use by another application on this Coolify instance.') + ->assertNotDispatched('success'); + + expect($application->settings()->first()->custom_container_name_prefix)->toBeNull(); +})->with(['submit', 'instantSave']); + +it('saves a valid container name prefix on every save path', function (string $method) { + $application = createApplicationForContainerNamingTest(); + $application = $application->fresh(['environment.project', 'settings', 'destination']); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('customContainerNamePrefix', 'my-api-2') + ->call($method) + ->assertDispatched('success'); + + expect($application->settings()->first()->custom_container_name_prefix)->toBe('my-api-2'); +})->with(['submit', 'instantSave']); + +it('keeps saving other settings when a stored prefix predates the format rule', function () { + $application = createApplicationForContainerNamingTest(); + $application->settings->update(['custom_container_name_prefix' => 'Legacy_Prefix']); + $application = $application->fresh(['environment.project', 'settings', 'destination']); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('isGitLfsEnabled', true) + ->call('instantSave') + ->assertDispatched('success'); + + $settings = $application->settings()->first(); + expect($settings->is_git_lfs_enabled)->toBeTrue() + ->and($settings->custom_container_name_prefix)->toBe('Legacy_Prefix'); +}); diff --git a/tests/Feature/NineRouterServiceTemplateTest.php b/tests/Feature/NineRouterServiceTemplateTest.php new file mode 100644 index 0000000000..9027a115c6 --- /dev/null +++ b/tests/Feature/NineRouterServiceTemplateTest.php @@ -0,0 +1,51 @@ + Process::result(output: 'NOK')]); +}); + +test('the 9router template generates secrets that docker compose reads unchanged from the .env file', function () { + $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(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => file_get_contents(base_path('templates/compose/9router.yaml')), + ]); + + $service->parse(); + + $secrets = $service->environment_variables() + ->where('key', 'like', 'SERVICE_PASSWORD%') + ->pluck('value', 'key'); + + // INITIAL_PASSWORD, JWT_SECRET and API_KEY_SECRET; the admin logs in with the shown password. + expect($secrets)->toHaveCount(3); + foreach ($secrets as $key => $value) { + expect($value)->toMatch('/^[A-Za-z0-9]+$/', "{$key} contains characters that compose interpolates"); + } +}); diff --git a/tests/Feature/Proxy/ProxyPortVariablesTest.php b/tests/Feature/Proxy/ProxyPortVariablesTest.php index 193de09421..c6af6871a3 100644 --- a/tests/Feature/Proxy/ProxyPortVariablesTest.php +++ b/tests/Feature/Proxy/ProxyPortVariablesTest.php @@ -96,6 +96,32 @@ it('asks to start a stopped proxy whose ports use variables and checks only conc Process::assertDidntRun(fn ($process) => str_contains($process->command, 'sport = ') && str_contains($process->command, '${')); }); +it('asks to start a stopped proxy whose ports all use variables without defaults', function () { + $this->server->proxy->last_saved_proxy_configuration = "services:\n traefik:\n image: traefik:v3.6\n ports:\n - '\${HTTP_PORT}:80'\n - '\${HTTPS_PORT}:443'\n"; + $this->server->save(); + + expect(CheckProxy::run($this->server))->toBeTrue(); + + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'sport = ')); +}); + +it('does not ask to start a stopped proxy without published ports', function () { + $this->server->proxy->last_saved_proxy_configuration = "services:\n traefik:\n image: traefik:v3.6\n ports:\n"; + $this->server->save(); + + expect(CheckProxy::run($this->server))->toBeFalse(); +}); + +it('saves and starts a proxy whose ports key is empty', function () { + $configuration = "services:\n traefik:\n image: traefik:v3.6\n ports:\n"; + + SaveProxyConfiguration::run($this->server, $configuration); + StartProxy::run($this->server->fresh(), async: false, force: true); + + expect($this->server->fresh()->proxy->last_saved_proxy_configuration)->toBe($configuration); + Process::assertRan(fn ($process) => str_contains($process->command, 'docker compose -f /data/coolify/proxy/docker-compose.yml up -d --wait --remove-orphans')); +}); + it('configures traffic analytics for a proxy whose ports use variables', function () { Queue::fake(); StartSentinel::partialMock()->shouldReceive('handle')->atLeast()->once(); diff --git a/tests/Feature/ScheduledJobManagerDispatchTest.php b/tests/Feature/ScheduledJobManagerDispatchTest.php index 4e201b90be..82f024c848 100644 --- a/tests/Feature/ScheduledJobManagerDispatchTest.php +++ b/tests/Feature/ScheduledJobManagerDispatchTest.php @@ -29,6 +29,7 @@ use App\Notifications\Server\DockerCleanupFailed; use App\Services\ScheduledJobDeliveryService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Queue\Middleware\WithoutOverlapping; +use Illuminate\Queue\TimeoutExceededException; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Cache; @@ -36,6 +37,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; uses(RefreshDatabase::class); @@ -359,7 +361,7 @@ it('does not query relationships when constructing scheduled task jobs', functio expect(DB::getQueryLog())->toBeEmpty() ->and($job->queue)->toBe(crons_queue()) - ->and($job->timeout)->toBe(300); + ->and($job->timeout)->toBe(300 + ScheduledTaskJob::WORKER_TIMEOUT_MARGIN_SECONDS); }); it('reads current server settings in each queued manager run of the same worker', function () { @@ -803,6 +805,40 @@ it('leaves recently enqueued occurrences alone', function () { expect($occurrence->fresh()->enqueued_at->toDateTimeString())->toBe('2026-09-17 11:01:00'); }); +it('closes occurrences that an earlier release left enqueued without failed executions or notifications', function () { + config(['constants.coolify.self_hosted' => true]); + Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC')); + Queue::fake(); + Notification::fake(); + Event::fake([DockerCleanupDone::class]); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + $task = createScheduledApplicationTask(createScheduledTaskApplication(), ['frequency' => 'daily']); + $team = Team::find($task->team_id); + $team->emailNotificationSettings->update(['smtp_enabled' => true, 'scheduled_task_failure_email_notifications' => true, 'docker_cleanup_failure_email_notifications' => true]); + $server = $task->application->destination->server; + $backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'daily']); + + $legacy = [ + createStaleEnqueuedOccurrence("scheduled-task:{$task->id}", 'scheduled-task', $task->id, Carbon::create(2026, 9, 10, 0, 0, 0, 'UTC'), minutesAgo: 7 * 24 * 60), + createStaleEnqueuedOccurrence("docker-cleanup:{$server->id}", 'docker-cleanup', $server->id, Carbon::create(2026, 9, 17, 9, 0, 0, 'UTC'), minutesAgo: 180), + createStaleEnqueuedOccurrence("scheduled-backup:{$backup->id}", 'database-backup', $backup->id, Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC')), + ]; + // A recent job can still be waiting in the queue. + $recent = createStaleEnqueuedOccurrence("scheduled-task:{$task->id}", 'scheduled-task', $task->id, Carbon::create(2026, 9, 17, 11, 55, 0, 'UTC'), minutesAgo: 5); + + (require database_path('migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php'))->up(); + (new ScheduledJobManager)->handle(); + + foreach ($legacy as $occurrence) { + expect($occurrence->fresh()->status)->toBe('skipped'); + } + expect($recent->fresh()->status)->toBe('enqueued') + ->and(ScheduledTaskExecution::query()->exists())->toBeFalse() + ->and(DockerCleanupExecution::query()->exists())->toBeFalse(); + Queue::assertNotPushed(DatabaseBackupJob::class); + Notification::assertNothingSent(); +}); + it('dispatches only the schedules of its own type', function (string $type, array $expected) { config(['constants.coolify.self_hosted' => true]); Carbon::setTestNow(Carbon::create(2026, 9, 17, 1, 0, 0, 'UTC')); @@ -997,18 +1033,51 @@ it('does not run a scheduled task again when Redis hands it out after its worker expect(ScheduledTaskExecution::query()->where('scheduled_task_id', $task->id)->exists())->toBeFalse(); }); -it('marks only the unfinished execution as failed when a scheduled task job fails without its execution id', function () { +it('fails only its own execution and notifies the team when the worker timeout stops a scheduled task job', function () { InstanceSettings::forceCreate(['id' => 0]); Notification::fake(); - $task = createScheduledApplicationTask(createScheduledTaskApplication()); - $interrupted = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'running', 'started_at' => now()->subHours(2)]); - $interrupted->forceFill(['created_at' => now()->subHours(2)])->save(); - $later = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'success', 'started_at' => now()->subHour(), 'finished_at' => now()->subMinutes(59)]); + Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC')); + $application = createScheduledTaskApplication(); + $task = createScheduledApplicationTask($application, ['timeout' => 300]); + $team = Team::find($task->team_id); + $team->emailNotificationSettings->update(['smtp_enabled' => true, 'scheduled_task_failure_email_notifications' => true]); + $finished = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'success', 'started_at' => now()->subHour(), 'finished_at' => now()->subMinutes(59)]); + $job = new ScheduledTaskJob($task); + // At the worker timeout, failed() runs on a copy rebuilt from the queued payload. + $queuedCopy = unserialize(serialize($job)); + $commands = collect(); + $atTimeout = null; + Process::fake(function ($process) use ($application, $task, $queuedCopy, $team, $commands, &$atTimeout) { + $commands->push($process->command); + if (str_contains($process->command, 'docker ps')) { + return Process::result(output: json_encode(['Names' => 'app-'.$application->uuid, 'Labels' => "coolify.applicationUuid={$application->uuid}", 'State' => 'running'])); + } + if (str_contains($process->command, 'docker exec')) { + Carbon::setTestNow(now()->addMinute()); + $newerRun = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'running', 'started_at' => now()]); + $ownRun = ScheduledTaskExecution::query()->whereKeyNot([$newerRun->id])->where('status', 'running')->sole(); - (new ScheduledTaskJob($task))->failed(new RuntimeException('worker killed')); + $queuedCopy->failed(new TimeoutExceededException('App\Jobs\ScheduledTaskJob has timed out.')); - expect($interrupted->fresh()->status)->toBe('failed') - ->and($later->fresh()->status)->toBe('success'); + $atTimeout = [ + 'own' => $ownRun->fresh()->status, + 'own_finished' => $ownRun->fresh()->finished_at !== null, + 'newer' => $newerRun->fresh()->status, + 'notifications' => Notification::sent($team, TaskFailed::class)->count(), + ]; + + throw new RuntimeException('The worker stopped the job.'); + } + + return Process::result(output: ''); + }); + + expect(fn () => $job->handle())->toThrow(RuntimeException::class); + + expect($atTimeout)->toBe(['own' => 'failed', 'own_finished' => true, 'newer' => 'running', 'notifications' => 1]) + ->and($finished->fresh()->status)->toBe('success') + ->and($commands->first(fn (string $command) => str_contains($command, 'docker exec')))->toStartWith('timeout 300 ssh ') + ->and($job->timeout)->toBeGreaterThan(300); }); it('runs a fixed-time schedule once when daylight saving time repeats its local time', function () { diff --git a/tests/Feature/SecretManagers/RemoteSecretResourceLifecycleTest.php b/tests/Feature/SecretManagers/RemoteSecretResourceLifecycleTest.php index c93d3a1849..986bec92e9 100644 --- a/tests/Feature/SecretManagers/RemoteSecretResourceLifecycleTest.php +++ b/tests/Feature/SecretManagers/RemoteSecretResourceLifecycleTest.php @@ -9,6 +9,7 @@ use App\Actions\Service\StopService; use App\Enums\ActivityTypes; use App\Enums\ProcessStatus; use App\Events\DatabaseStatusChanged; +use App\Exceptions\RemoteSecretException; use App\Jobs\ApplicationDeploymentJob; use App\Jobs\DatabaseStartJob; use App\Livewire\Project\Shared\EnvironmentVariable\Show; @@ -210,6 +211,28 @@ test('a service restart keeps the service running when remote secrets cannot be ->toThrow(RuntimeException::class, 'Doppler is down'); }); +test('a literal service variable keeps reference text when no secret manager source is configured', function () { + Http::fake(); + $service = remoteSecretLifecycleService(); + $service->secretManagerLink()->delete(); + $service->environment_variables()->create(['key' => 'TEMPLATE', 'value' => 'Hello {{vault.NAME}}', 'is_literal' => true]); + + $service->ensureRemoteSecretsResolvable($service->environment_variables()->get()); + + expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain("TEMPLATE='Hello {{vault.NAME}}'"); + Http::assertNothingSent(); +}); + +test('a service start stops before the service when a non-literal variable references a secret without a source', function () { + StopService::shouldRun()->never(); + $service = remoteSecretLifecycleService(); + $service->secretManagerLink()->delete(); + $service->environment_variables()->create(['key' => 'API_KEY', 'value' => '{{vault.API_KEY}}']); + + expect(fn () => StartService::run($service, stopBeforeStart: true)) + ->toThrow(RemoteSecretException::class, 'no secret manager source is configured'); +}); + test('a service restart fetches remote secrets only once', function () { Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['API_KEY' => 'value'])]); Process::fake(['*' => Process::result(output: '')]); diff --git a/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php b/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php index 1900ddb588..d1a667fdaf 100644 --- a/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php +++ b/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php @@ -12,6 +12,7 @@ use App\Models\Project; use App\Models\Server; use App\Models\Team; use App\Models\User; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use Illuminate\Support\Js; @@ -382,6 +383,35 @@ test('the edit modal value autocomplete offers the vault scope with lazy key fet expect($component->instance()->fetchSecretManagerKeys())->toBe(['DB_PASSWORD']); }); +test('the value autocomplete lists secret manager keys only for users who can update the resource', function (string $role, bool $allowed) { + Http::fake([ + 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([ + 'DB_PASSWORD' => 'super-secret-value', + ]), + ]); + $this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]); + $env = $this->application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']); + + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => $role]); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']); + + if ($allowed) { + expect($component->instance()->fetchSecretManagerKeys())->toBe(['DB_PASSWORD']); + + return; + } + + expect(fn () => $component->instance()->fetchSecretManagerKeys())->toThrow(AuthorizationException::class); + Http::assertNothingSent(); +})->with([ + 'member' => ['member', false], + 'admin' => ['admin', true], +]); + test('the edit modal value autocomplete reports secret provider failures', function () { Http::fake([ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([], 503), diff --git a/tests/Feature/ServerProxyConfigurationAuthorizationTest.php b/tests/Feature/ServerProxyConfigurationAuthorizationTest.php new file mode 100644 index 0000000000..698d194e06 --- /dev/null +++ b/tests/Feature/ServerProxyConfigurationAuthorizationTest.php @@ -0,0 +1,58 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->proxyConfiguration = "services:\n traefik:\n image: traefik:v3.6\n environment:\n - CF_DNS_API_TOKEN=zzfix-cloudflare-secret\n ports:\n - '80:80'\n"; + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => 'exited', + 'last_saved_proxy_configuration' => $this->proxyConfiguration, + ], + ]); +}); + +function actingAsProxyTeamUser(Team $team, string $role): User +{ + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role]); + test()->actingAs($user); + session(['currentTeam' => $team]); + + return $user; +} + +it('does not load the proxy configuration for a team member', function () { + actingAsProxyTeamUser($this->team, 'member'); + + $component = Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('loadProxyConfiguration') + ->assertOk() + ->assertNotDispatched('error') + ->assertSet('proxySettings', null); + + expect($component->html())->not->toContain('zzfix-cloudflare-secret'); +}); + +it('loads the proxy configuration for a team admin', function () { + actingAsProxyTeamUser($this->team, 'admin'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('loadProxyConfiguration') + ->assertSet('proxySettings', $this->proxyConfiguration); +}); diff --git a/tests/Feature/SettingsBackupEmptyStateTest.php b/tests/Feature/SettingsBackupEmptyStateTest.php index 7bdf94341e..605350beb8 100644 --- a/tests/Feature/SettingsBackupEmptyStateTest.php +++ b/tests/Feature/SettingsBackupEmptyStateTest.php @@ -2,6 +2,7 @@ use App\Livewire\SettingsBackup; use App\Models\InstanceSettings; +use App\Models\PrivateKey; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandaloneDocker; @@ -11,6 +12,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Once; use Livewire\Livewire; @@ -161,3 +163,64 @@ test('configure backup creates only the missing schedule for an existing instanc ->and($backup->frequency)->toBe('0 0 * * *') ->and(ScheduledDatabaseBackup::query()->count())->toBe(1); }); + +/** + * A team database that is also named coolify-db, but is not the instance database (id 0). + */ +function settingsBackupTenantDatabaseNamedCoolifyDb(): StandalonePostgresql +{ + $tenantTeam = Team::factory()->create(); + $tenantServer = Server::factory()->create(['team_id' => $tenantTeam->id]); + $tenantDestination = StandaloneDocker::query()->where('server_id', $tenantServer->id)->firstOrFail(); + + $database = new StandalonePostgresql; + $database->forceFill([ + 'name' => 'coolify-db', + 'postgres_user' => 'tenant', + 'postgres_password' => 'tenant-secret', + 'postgres_db' => 'tenant', + 'status' => 'running', + 'destination_type' => StandaloneDocker::class, + 'destination_id' => $tenantDestination->id, + ]); + $database->save(); + + return $database; +} + +test('instance backup settings ignore a team database named coolify-db', function () { + settingsBackupInstanceDatabaseWithoutSchedule()->delete(); + $tenantDatabase = settingsBackupTenantDatabaseNamedCoolifyDb(); + + Livewire::test(SettingsBackup::class) + ->assertOk() + ->assertSet('database', null) + ->assertSee('Backup is not configured') + ->assertDontSee('tenant-secret'); + + expect($tenantDatabase->fresh()->scheduledBackups()->exists())->toBeFalse(); +}); + +test('configure backup creates the instance database instead of using a team database named coolify-db', function () { + settingsBackupInstanceDatabaseWithoutSchedule()->forceDelete(); + $tenantDatabase = settingsBackupTenantDatabaseNamedCoolifyDb(); + Storage::fake('ssh-keys'); + Storage::fake('ssh-mux'); + Server::query()->whereKey(0)->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => 0])->id]); + Server::flushIdentityMap(); + Process::fake([ + '*docker inspect coolify-db*' => Process::result(output: json_encode([[ + 'Config' => ['Env' => ['POSTGRES_USER=coolify', 'POSTGRES_PASSWORD=instance-secret', 'POSTGRES_DB=coolify']], + ]])), + ]); + + Livewire::test(SettingsBackup::class) + ->call('addCoolifyDatabase') + ->assertHasNoErrors() + ->assertSet('postgres_password', 'instance-secret'); + + $instanceDatabase = StandalonePostgresql::query()->findOrFail(0); + expect($instanceDatabase->scheduledBackups()->sole()->team_id)->toBe(0) + ->and($tenantDatabase->fresh()->scheduledBackups()->exists())->toBeFalse() + ->and($tenantDatabase->fresh()->postgres_password)->toBe('tenant-secret'); +}); diff --git a/tests/Feature/SqliteConnectApplicationTest.php b/tests/Feature/SqliteConnectApplicationTest.php index 258ac78b94..9757498c80 100644 --- a/tests/Feature/SqliteConnectApplicationTest.php +++ b/tests/Feature/SqliteConnectApplicationTest.php @@ -18,6 +18,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; +use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -219,6 +220,28 @@ it('forbids team members from mounting the volume into an application', function expect($this->application->persistentStorages()->count())->toBe(0); }); +it('rejects a client-supplied volume name so the mount cannot become a host bind mount', function (string $volumeName) { + expect(fn () => Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]) + ->set('applicationUuid', $this->application->uuid) + ->set('volumeName', $volumeName) + ->call('connect')) + ->toThrow(CannotUpdateLockedPropertyException::class); + + expect($this->application->persistentStorages()->count())->toBe(0); +})->with(['/', '../x', '/etc']); + +it('does not mount a stored data volume whose name is not a docker volume name', function () { + $this->sqlite->persistentStorages()->update(['name' => '/etc']); + + Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]) + ->set('applicationUuid', $this->application->uuid) + ->call('connect') + ->assertHasErrors('volumeName') + ->assertNoRedirect(); + + expect($this->application->persistentStorages()->count())->toBe(0); +}); + it('keeps a docker volume shared with another resource when the application volumes are deleted', function () { Process::fake(); diff --git a/tests/Feature/StartDatabaseImportBackupPreparationTest.php b/tests/Feature/StartDatabaseImportBackupPreparationTest.php index 4016fb6dac..b49d607a84 100644 --- a/tests/Feature/StartDatabaseImportBackupPreparationTest.php +++ b/tests/Feature/StartDatabaseImportBackupPreparationTest.php @@ -11,6 +11,7 @@ use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; use App\Models\Team; +use App\Support\DatabaseImport\DatabaseImportException; use App\Support\DatabaseImport\DatabaseImportSource; use App\Support\RemoteProcessCommand; use App\Support\ResourceStartActivity; @@ -88,6 +89,56 @@ test('plain and gzip server backups are copied into the database container uncha 'custom archive' => ['/srv/backups/app.dmp', '5047444d5001'], ]); +test('server backups without a known backup extension can be restored', function (string $path, string $magicHex) { + $command = importServerBackup($this, $path, $magicHex); + + expect($command)->toContain("docker cp '{$path}' '{$this->database->uuid}:/tmp/restore_"); +})->with([ + 'no extension' => ['/srv/backups/pgdump', '5047444d5001'], + 'date suffix' => ['/srv/backups/app.sql.2026-10-01', '2d2d20506f73'], + 'sqlite' => ['/srv/backups/app.sqlite', '53514c697465'], +]); + +test('server backup paths with shell characters are still rejected', function (string $path) { + expect(fn () => importServerBackup($this, $path, '2d2d20506f73')) + ->toThrow(DatabaseImportException::class, 'The server path is invalid.'); +})->with([ + 'relative' => ['srv/backups/pgdump'], + 'traversal' => ['/srv/backups/../../etc/shadow'], + 'command substitution' => ['/srv/backups/$(id)'], + 'semicolon' => ['/srv/backups/a;id'], + 'quote' => ["/srv/backups/a'b"], +]); + +test('s3 backups still need a known backup extension', function (string $path, bool $allowed) { + $storage = S3Storage::create([ + 'name' => 'Import S3', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://8.8.8.8', + 'is_usable' => true, + 'team_id' => $this->team->id, + ]); + $disk = Mockery::mock(FilesystemAdapter::class); + $disk->shouldReceive('exists')->andReturn(false); + $filesystem = Mockery::mock(FilesystemManager::class, [app()])->makePartial(); + $filesystem->shouldReceive('build')->andReturn($disk); + Storage::swap($filesystem); + Process::fake(); + + expect(fn () => app(StartDatabaseImport::class)->handle( + $this->database, + new DatabaseImportSource('s3', path: $path, s3StorageUuid: $storage->uuid), + $this->team->id, + ))->toThrow(DatabaseImportException::class, $allowed ? 'The S3 backup was not found' : 'The S3 path is invalid.'); +})->with([ + 'no extension' => ['backups/pgdump', false], + 'sqlite' => ['backups/app.sqlite', true], + 'gzip sqlite3' => ['backups/app.sqlite3.gz', true], +]); + test('bz2, xz, and zip server backups are prepared in the helper image', function (string $path, string $magicHex) { $command = importServerBackup($this, $path, $magicHex); diff --git a/tests/Feature/StorageAndServiceAuditTest.php b/tests/Feature/StorageAndServiceAuditTest.php index 0ec3dcee9a..e0520fa867 100644 --- a/tests/Feature/StorageAndServiceAuditTest.php +++ b/tests/Feature/StorageAndServiceAuditTest.php @@ -248,7 +248,7 @@ test('converting and deleting a file storage records storage events', function ( ]); Livewire::test(FileStorage::class, ['fileStorage' => $file->fresh()]) - ->call('delete', 'password') + ->call('delete', 'password', ['permanently_delete']) ->assertDispatched('success'); expect($file->fresh())->toBeNull() diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php index 30d4849b1c..2b0774d8ff 100644 --- a/tests/Feature/StorageApiTest.php +++ b/tests/Feature/StorageApiTest.php @@ -184,7 +184,7 @@ describe('GET /api/v1/applications/{uuid}/storages', function () { }); describe('POST /api/v1/applications/{uuid}/storages', function () { - test('rejects an application directory mount outside its managed root without side effects', function () { + test('creates an application directory mount outside its resource directory for an owner', function () { $app = createTestApplication($this); $this->withHeaders([ @@ -194,10 +194,9 @@ describe('POST /api/v1/applications/{uuid}/storages', function () { 'is_directory' => true, 'fs_path' => '/root/.ssh/authorized_keys', 'mount_path' => '/data', - ])->assertUnprocessable(); + ])->assertCreated(); - expect($app->fileStorages()->exists())->toBeFalse(); - Bus::assertNotDispatched(ServerStorageSaveJob::class); + expect($app->fileStorages()->sole()->fs_path)->toBe('/root/.ssh/authorized_keys'); }); test('creates a persistent storage', function () { @@ -685,7 +684,7 @@ test('rejects host paths when creating persistent storage through the API', func expect($resource->persistentStorages()->count())->toBe($storageCountBefore); })->with(['application', 'database', 'service']); -test('rejects directory mounts outside each resource configuration root', function (string $resourceType) { +test('creates directory mounts outside each resource configuration root for an owner', function (string $resourceType) { if ($resourceType === 'database') { $resource = createTestDatabase($this); $url = "/api/v1/databases/{$resource->uuid}/storages"; @@ -703,10 +702,9 @@ test('rejects directory mounts outside each resource configuration root', functi 'is_directory' => true, 'fs_path' => '/etc/shadow', 'mount_path' => '/data', - ]])->assertUnprocessable(); + ]])->assertCreated(); - expect($resource->fileStorages()->exists())->toBeFalse(); - Bus::assertNotDispatched(ServerStorageSaveJob::class); + expect($resource->fileStorages()->sole()->fs_path)->toBe('/etc/shadow'); })->with(['database', 'service']); test('rejects a directory mount through a remote symlink before creating storage', function (string $resourceType) { diff --git a/tests/Feature/StorageHostPathTest.php b/tests/Feature/StorageHostPathTest.php new file mode 100644 index 0000000000..166cd07563 --- /dev/null +++ b/tests/Feature/StorageHostPathTest.php @@ -0,0 +1,365 @@ + 0, 'is_api_enabled' => true]); + config([ + 'app.maintenance.store' => 'array', + 'cache.default' => 'array', + 'constants.ssh.mux_enabled' => false, + ]); + + $this->team = Team::factory()->create(); + $this->admin = User::factory()->create(); + $this->admin->teams()->attach($this->team, ['role' => 'admin']); + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + storageHostPathFakeServer(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); +}); + +/** + * `test -L` prints $symlink and `test -f`/`test -d` print $exists. + */ +function storageHostPathFakeServer(string $symlink = 'OK', string $exists = 'NOK'): void +{ + Process::fake(fn ($process) => Process::result(output: match (true) { + str_contains($process->command, 'test -L') => $symlink, + str_contains($process->command, 'readlink -f') => 'OK', + str_contains($process->command, 'test -') => $exists, + default => '', + })); +} + +function storageHostPathToken(User $user): string +{ + // A token request must not use the session user of the Livewire tests. + auth()->forgetGuards(); + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'host-path-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => test()->team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +/** + * @return array{0: string, 1: array, 2: mixed} + */ +function storageHostPathApiTarget(string $resourceType): array +{ + if ($resourceType === 'application') { + return ['/api/v1/applications/'.test()->application->uuid.'/storages', [], test()->application]; + } + if ($resourceType === 'database') { + $database = StandalonePostgresql::create([ + 'name' => 'host-path-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ]); + + return ["/api/v1/databases/{$database->uuid}/storages", [], $database]; + } + + $service = Service::factory()->create([ + 'environment_id' => test()->environment->id, + 'server_id' => test()->server->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n", + ]); + $serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]); + + return ["/api/v1/services/{$service->uuid}/storages", ['resource_uuid' => $serviceApplication->uuid], $serviceApplication]; +} + +function storageHostPathFileVolume(string $fsPath, ?string $content, bool $isDirectory = false): LocalFileVolume +{ + return LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => '/mnt/'.md5($fsPath), + 'content' => $content, + 'is_directory' => $isDirectory, + 'resource_id' => test()->application->id, + 'resource_type' => test()->application->getMorphClass(), + ])->fresh(); +} + +function storageHostPathAssertNoDelete(): void +{ + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -') || str_contains($process->command, 'rmdir')); +} + +test('an admin creates a directory mount anywhere on the host in the UI', function () { + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_directory_source', '/srv/zz') + ->set('file_storage_directory_destination', '/data') + ->call('submitFileStorageDirectory') + ->assertDispatched('success'); + + $volume = LocalFileVolume::query()->sole(); + expect($volume->fs_path)->toBe('/srv/zz') + ->and($volume->is_directory)->toBeTrue(); + + $volume->saveStorageOnServer(); + Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '/srv/zz'")); +}); + +test('an admin creates a file mount with content anywhere on the host in the UI', function () { + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_source', '/etc/zz/app.conf') + ->set('file_storage_path', '/etc/app.conf') + ->set('file_storage_content', 'listen 8080;') + ->call('submitFileStorage') + ->assertDispatched('success'); + + $volume = LocalFileVolume::query()->sole(); + expect($volume->fs_path)->toBe('/etc/zz/app.conf') + ->and($volume->mount_path)->toBe('/etc/app.conf'); + + $volume->saveStorageOnServer(); + Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '/etc/zz'")); + Process::assertRan(fn ($process) => preg_match("#base64 -d \\| (sudo )?tee '/etc/zz/app.conf' > /dev/null#", $process->command) === 1); + expect($volume->contentPathOnServer())->toBe('/etc/zz/app.conf'); +}); + +test('an admin creates directory and file mounts anywhere on the host through the API', function (string $resourceType) { + [$url, $payload, $resource] = storageHostPathApiTarget($resourceType); + $token = storageHostPathToken($this->admin); + + $this->withHeaders(['Authorization' => 'Bearer '.$token])->postJson($url, [...$payload, ...[ + 'type' => 'file', + 'is_directory' => true, + 'fs_path' => '/srv/zz', + 'mount_path' => '/data', + ]])->assertCreated()->assertJsonPath('fs_path', '/srv/zz'); + + $this->withHeaders(['Authorization' => 'Bearer '.$token])->postJson($url, [...$payload, ...[ + 'type' => 'file', + 'fs_path' => '/etc/zz/app.conf', + 'mount_path' => '/etc/app.conf', + 'content' => 'listen 8080;', + ]])->assertCreated()->assertJsonPath('fs_path', '/etc/zz/app.conf'); + + expect($resource->fileStorages()->orderBy('id')->pluck('fs_path')->all())->toBe(['/srv/zz', '/etc/zz/app.conf']); +})->with(['application', 'database', 'service']); + +test('members cannot create host path mounts', function () { + $this->actingAs($this->member); + + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_directory_source', '/srv/zz') + ->set('file_storage_directory_destination', '/data') + ->call('submitFileStorageDirectory') + ->assertDispatched('error'); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->member)]) + ->postJson("/api/v1/applications/{$this->application->uuid}/storages", [ + 'type' => 'file', + 'fs_path' => '/etc/zz/app.conf', + 'mount_path' => '/etc/app.conf', + 'content' => 'listen 8080;', + ])->assertForbidden(); + + expect(LocalFileVolume::query()->count())->toBe(0); +}); + +test('host paths with traversal, home or shell characters are rejected', function (string $path) { + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_directory_source', $path) + ->set('file_storage_directory_destination', '/data') + ->call('submitFileStorageDirectory') + ->assertDispatched('error'); + + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_source', $path) + ->set('file_storage_path', '/etc/app.conf') + ->set('file_storage_content', 'listen 8080;') + ->call('submitFileStorage') + ->assertDispatched('error'); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)]) + ->postJson("/api/v1/applications/{$this->application->uuid}/storages", [ + 'type' => 'file', + 'is_directory' => true, + 'fs_path' => $path, + 'mount_path' => '/data', + ])->assertUnprocessable(); + + expect(LocalFileVolume::query()->count())->toBe(0); +})->with([ + 'parent segment' => '/srv/../etc/zz', + 'relative escape' => '../../../etc/zz', + 'home directory' => '~/zz', + 'command separator' => '/srv/zz;id', + 'command substitution' => '/srv/$(id)', +]); + +test('a symbolic link at a file path outside the resource directory is refused', function () { + storageHostPathFakeServer(symlink: 'LINK'); + $volume = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;'); + + expect(fn () => $volume->saveStorageOnServer()) + ->toThrow(RuntimeException::class, '/etc/zz/app.conf is a symbolic link on the server.'); + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'base64 -d')); + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'touch ')); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)]) + ->postJson("/api/v1/applications/{$this->application->uuid}/storages", [ + 'type' => 'file', + 'fs_path' => '/etc/zz/other.conf', + 'mount_path' => '/etc/other.conf', + 'content' => 'listen 8080;', + ])->assertUnprocessable(); + + expect(LocalFileVolume::query()->count())->toBe(1); +}); + +test('deleting a mount outside the resource directory never deletes on the host', function () { + storageHostPathFakeServer(exists: 'OK'); + $directory = storageHostPathFileVolume('/srv/zz', null, isDirectory: true); + $file = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;'); + + $directory->deleteStorageOnServer(); + $file->deleteStorageOnServer(); + storageHostPathAssertNoDelete(); + + Livewire::test(FileStorage::class, ['fileStorage' => $file->fresh()]) + ->set('permanently_delete', true) + ->call('delete', 'password') + ->assertDispatched('success', 'File deleted.'); + + storageHostPathAssertNoDelete(); + expect(LocalFileVolume::query()->pluck('fs_path')->all())->toBe(['/srv/zz']); +}); + +test('deleting a mount inside the resource directory still deletes it on the host', function () { + storageHostPathFakeServer(exists: 'OK'); + $path = $this->application->workdir().'/data/zz'; + + storageHostPathFileVolume($path, null, isDirectory: true)->deleteStorageOnServer(); + + Process::assertRan(fn ($process) => str_contains($process->command, "rm -rf '{$path}'")); +}); + +test('the UI deletes a mount inside the resource directory on the host only when the user keeps the option checked', function (bool $isDirectory, array $selectedActions, bool $deletesOnHost, string $message) { + storageHostPathFakeServer(exists: 'OK'); + $path = $this->application->workdir().'/data/zz'; + $volume = storageHostPathFileVolume($path, $isDirectory ? null : 'listen 8080;', isDirectory: $isDirectory); + + Livewire::test(FileStorage::class, ['fileStorage' => $volume]) + ->call('delete', 'password', $selectedActions) + ->assertDispatched('success', $message); + + expect($volume->fresh())->toBeNull(); + if ($deletesOnHost) { + Process::assertRan(fn ($process) => str_contains($process->command, "rm -rf '{$path}'")); + } else { + storageHostPathAssertNoDelete(); + } +})->with([ + 'directory, option checked' => [true, ['permanently_delete'], true, 'Directory deleted from the server.'], + 'directory, option unchecked' => [true, [], false, 'Directory deleted.'], + 'file, option checked' => [false, ['permanently_delete'], true, 'File deleted from the server.'], + 'file, option unchecked' => [false, [], false, 'File deleted.'], +]); + +test('the delete dialog offers deletion on the host only for mounts inside the resource directory', function () { + $inside = storageHostPathFileVolume($this->application->workdir().'/data/zz', 'listen 8080;'); + $outside = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;'); + $outsideDirectory = storageHostPathFileVolume('/srv/zz', null, isDirectory: true); + + $insideDialog = Livewire::test(FileStorage::class, ['fileStorage' => $inside]); + expect($insideDialog->viewData('deletionCheckboxes'))->toHaveCount(1) + ->and($insideDialog->viewData('deletionCheckboxes')[0]['id'])->toBe('permanently_delete'); + + foreach ([$outside, $outsideDirectory] as $volume) { + $dialog = Livewire::test(FileStorage::class, ['fileStorage' => $volume]); + expect($dialog->viewData('deletionCheckboxes'))->toBe([]) + ->and(implode(' ', $dialog->viewData('deletionActions')))->toContain('is not deleted on the server'); + } +}); + +test('a file outside the resource directory never replaces a directory on the host', function () { + Process::fake(fn ($process) => Process::result(output: match (true) { + str_contains($process->command, 'test -L') => 'OK', + str_contains($process->command, 'test -d') => 'OK', + str_contains($process->command, 'test -f') => 'NOK', + str_contains($process->command, 'sh -c') => '1:empty-directory', + default => '', + })); + + expect(fn () => storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;')->saveStorageOnServer()) + ->toThrow(Exception::class, 'is a directory on the server'); + storageHostPathAssertNoDelete(); +}); + +test('relative host paths stay inside the resource directory', function () { + $workdir = $this->application->workdir(); + + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_directory_source', 'data/zz') + ->set('file_storage_directory_destination', '/data') + ->call('submitFileStorageDirectory') + ->assertDispatched('success'); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)]) + ->postJson("/api/v1/applications/{$this->application->uuid}/storages", [ + 'type' => 'file', + 'fs_path' => './config/app.conf', + 'mount_path' => '/etc/app.conf', + 'content' => 'listen 8080;', + ])->assertCreated(); + + expect(LocalFileVolume::query()->orderBy('id')->pluck('fs_path')->all()) + ->toBe([$workdir.'/data/zz', $workdir.'/config/app.conf']); + Process::assertRan(fn ($process) => str_contains($process->command, 'readlink -f') + && str_contains($process->command, "'{$workdir}' '{$workdir}/data/zz'")); +}); diff --git a/tests/Feature/TraefikVersionStateTest.php b/tests/Feature/TraefikVersionStateTest.php index c3388e3a02..320993b9c5 100644 --- a/tests/Feature/TraefikVersionStateTest.php +++ b/tests/Feature/TraefikVersionStateTest.php @@ -178,6 +178,9 @@ it('still offers a newer Traefik branch than the configured image', function () it('clears the stale minor warning after the configured branch is applied', function () { $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); $server = Server::factory()->create([ 'team_id' => $team->id, 'proxy' => [ diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php new file mode 100644 index 0000000000..e489f8d80b --- /dev/null +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php @@ -0,0 +1,94 @@ + false, 'constants.ssh.retry_base_delay' => 0]); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->update(['is_traffic_analytics_enabled' => true]); +}); + +function markTrafficServerReachable(Server $server): void +{ + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $server->refresh(); +} + +it('does not run a remote command for a server that is not reachable', function () { + Process::fake(fn () => Process::result(output: '{"requests":1}')); + $this->server->settings->update(['is_reachable' => false]); + $client = new SentinelTrafficClient($this->server->fresh()); + [$from, $to] = SentinelTrafficClient::rangeWindow('24h'); + + expect(fn () => $client->overview(null, $from, $to))->toThrow(RuntimeException::class); + $client->prefetchAppOverviews(['app'], $from, $to); + + Process::assertNothingRan(); +}); + +it('skips the dashboard fetch of an unreachable server without a remote call', function () { + Process::fake(fn () => Process::result(output: '{"requests":1}')); + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + loadLazy(Livewire::test(TrafficAnalytics::class)) + ->assertOk() + ->assertSet('overview', null); + + Process::assertNothingRan(); +}); + +it('tries a failing server once and skips it until the failure window ends', function () { + markTrafficServerReachable($this->server); + Process::fake(fn () => Process::result( + errorOutput: "ssh: connect to host {$this->server->ip} port 22: Connection timed out", + exitCode: 255, + )); + $client = new SentinelTrafficClient($this->server); + + expect(fn () => $client->overview(null, 'a', 'b'))->toThrow(RuntimeException::class); + Process::assertRanTimes(fn () => true, 1); + + expect(fn () => $client->overview('app', 'a', 'b'))->toThrow(RuntimeException::class); + Process::assertRanTimes(fn () => true, 1); + + $this->travel(SentinelTrafficClient::UNAVAILABLE_TTL + 1)->seconds(); + + expect(fn () => $client->overview('app', 'a', 'b'))->toThrow(RuntimeException::class); + Process::assertRanTimes(fn () => true, 2); +}); + +it('fetches a healthy server with a bounded ssh and curl timeout', function () { + markTrafficServerReachable($this->server); + Process::fake(fn () => Process::result(output: '{"requests":7}')); + + $overview = (new SentinelTrafficClient($this->server))->overview(null, 'a', 'b'); + + expect($overview->requests)->toBe(7); + Process::assertRan(fn (PendingProcess $process) => $process->timeout <= 30 + && str_contains($process->command, '--max-time')); +}); diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficCacheTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficCacheTest.php index 233ac6c7d7..2b5312b26f 100644 --- a/tests/Feature/TrafficAnalytics/SentinelTrafficCacheTest.php +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficCacheTest.php @@ -42,6 +42,8 @@ it('surfaces the real remote error instead of a TypeError when the fetch fails', 'team_id' => $team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, ]); + // Only a functional server is queried. + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); expect(fn () => (new SentinelTrafficClient($server))->overview('app', 'a', 'b')) ->toThrow(RuntimeException::class, $message); diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php index a804036682..7919c5e212 100644 --- a/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php @@ -25,6 +25,8 @@ beforeEach(function () { 'team_id' => $team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, ]); + // Only a functional server is queried. + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]); $this->client = new class($this->server) extends SentinelTrafficClient { diff --git a/tests/Feature/TrafficAnalytics/TrafficLockedPropertiesTest.php b/tests/Feature/TrafficAnalytics/TrafficLockedPropertiesTest.php new file mode 100644 index 0000000000..15d68461df --- /dev/null +++ b/tests/Feature/TrafficAnalytics/TrafficLockedPropertiesTest.php @@ -0,0 +1,66 @@ + 0]); + $this->user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings->update(['is_traffic_analytics_enabled' => false]); +}); + +it('locks the scoped server and chart id of the analytics page', function (string $property, string $value) { + $component = loadLazy(Livewire::test(Analytics::class, ['scopedServerUuid' => $this->server->uuid])); + + expect(fn () => $component->set($property, $value))->toThrow(CannotUpdateLockedPropertyException::class); +})->with([ + 'scoped server' => ['scopedServerUuid', 'other-server'], + 'chart id' => ['chartId', "x');alert(1);//"], +]); + +it('locks the server uuid and chart id of the resource analytics tab', function (string $property, string $value) { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $destination = StandaloneDocker::where('server_id', $this->server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-locked-test']); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + ]); + + $component = Livewire::test(ApplicationAnalytics::class, ['application' => $application, 'lazy' => false]); + + expect(fn () => $component->set($property, $value))->toThrow(CannotUpdateLockedPropertyException::class); +})->with([ + 'analytics server' => ['analyticsServerUuid', 'other-server'], + 'chart id' => ['chartId', "x');alert(1);//"], +]); + +it('locks the chart id of the dashboard traffic summary', function () { + $component = loadLazy(Livewire::test(TrafficAnalytics::class)); + + expect(fn () => $component->set('chartId', "x');alert(1);//"))->toThrow(CannotUpdateLockedPropertyException::class); +}); diff --git a/tests/Feature/TrafficAnalytics/TrafficSettingsLimitsTest.php b/tests/Feature/TrafficAnalytics/TrafficSettingsLimitsTest.php new file mode 100644 index 0000000000..7e11e04c13 --- /dev/null +++ b/tests/Feature/TrafficAnalytics/TrafficSettingsLimitsTest.php @@ -0,0 +1,74 @@ + 'array', + 'cache.default' => 'array', + ]); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings->update(['is_traffic_analytics_enabled' => false]); +}); + +dataset('traffic setting limits', [ + 'top-n' => ['trafficTopn', 'traffic_topn', 1000], + 'hourly retention' => ['trafficRetention1hDays', 'traffic_retention_1h_days', 365], + 'daily retention' => ['trafficRetention1dDays', 'traffic_retention_1d_days', 3650], +]); + +it('rejects traffic settings above the limit in the UI', function (string $property, string $column, int $max) { + $this->actingAs($this->user); + $before = $this->server->settings->fresh()->{$column}; + + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $this->server]) + ->set($property, $max + 1) + ->call('saveTrafficAnalyticsSettings') + ->assertHasErrors($property); + + expect($this->server->settings->fresh()->{$column})->toBe($before); + + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $this->server]) + ->set($property, $max) + ->call('saveTrafficAnalyticsSettings') + ->assertHasNoErrors(); + + expect($this->server->settings->fresh()->{$column})->toBe($max); +})->with('traffic setting limits'); + +it('rejects traffic settings above the limit in the API', function (string $property, string $column, int $max) { + $token = $this->user->createToken('traffic-limits', ['*'])->plainTextToken; + $headers = ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json']; + $before = $this->server->settings->fresh()->{$column}; + + $this->withHeaders($headers) + ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [$column => $max + 1]) + ->assertUnprocessable() + ->assertJsonValidationErrors($column); + + expect($this->server->settings->fresh()->{$column})->toBe($before); + + $this->withHeaders($headers) + ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [$column => $max]) + ->assertOk() + ->assertJsonPath($column, $max); +})->with('traffic setting limits'); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 2fc39486d5..55aec44db1 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -209,6 +209,61 @@ it('shows readable service storage backup target labels', function () { ->assertSee('Directus: directus-templates'); }); +it('assigns a new volume backup schedule to the team of its resource, not the current team', function (string $resourceType) { + $team = Team::factory()->create(); + $user = signInForVolumeBackups($this, $team); + [$application, $volume, $server] = createVolumeBackupApplication($team); + $otherTeam = Team::factory()->create(); + $user->teams()->attach($otherTeam, ['role' => 'owner']); + session(['currentTeam' => $otherTeam]); + + if ($resourceType === 'service') { + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ]); + $serviceApplication = ServiceApplication::create(['uuid' => new_public_id(), 'name' => 'app', 'service_id' => $service->id]); + $volume = LocalPersistentVolume::create([ + 'name' => $service->uuid.'_app-data', + 'mount_path' => '/data', + 'resource_id' => $serviceApplication->id, + 'resource_type' => $serviceApplication->getMorphClass(), + ]); + $component = Livewire::test(CreateServiceVolumeBackup::class, ['service' => $service, 'selectedTargetKey' => 'volume:'.$volume->id]); + } else { + $component = Livewire::test(CreateScheduledVolumeBackup::class, ['application' => $application, 'selectedTargetKey' => 'volume:'.$volume->id]); + } + + $component->set('frequency', 'daily') + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect(ScheduledVolumeBackup::query()->sole()->team_id)->toBe($team->id); +})->with(['application', 'service']); + +it('assigns a volume backup schedule saved from its settings page to the team of its resource, not the current team', function (bool $rootTeam) { + $team = $rootTeam ? Team::factory()->create(['id' => 0]) : Team::factory()->create(); + $user = signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $otherTeam = Team::factory()->create(); + $user->teams()->attach($otherTeam, ['role' => 'owner']); + session(['currentTeam' => $otherTeam]); + + Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->set('frequency', 'daily') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect(ScheduledVolumeBackup::query()->sole()->team_id)->toBe($team->id); +})->with([ + 'team' => [false], + 'root team' => [true], +]); + it('handles scheduled backup persistence failures', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php index 4b924b5f35..f62697554f 100644 --- a/tests/Feature/Webhook/WebhookHmacTest.php +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -124,24 +124,18 @@ function makeWebhookApplicationServerFunctional(Application $application): Appli } describe('Manual Webhook Failed Authentication Rate Limiting', function () { - test('a locked failure scope rejects deliveries before reading application secrets', function (string $provider, bool $validSignature) { - $application = createApplicationWithWebhook(); + test('a locked failure scope rejects invalid deliveries', function (string $provider) { + Queue::fake(); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook()); lockOutManualWebhookRepository($this, $provider, $application); - DB::enableQueryLog(); - DB::flushQueryLog(); - sendManualWebhookPush($this, $provider, $application, validSignature: $validSignature) + sendManualWebhookPush($this, $provider, $application, validSignature: false) ->assertStatus(429) ->assertHeader('Retry-After'); - $applicationQueries = collect(DB::getQueryLog()) - ->filter(fn (array $query): bool => str_contains($query['query'], '"applications"')); - DB::disableQueryLog(); - - expect($applicationQueries)->toBeEmpty(); expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); - })->with(['github', 'gitlab', 'bitbucket', 'gitea'])->with([false, true]); + })->with(['github', 'gitlab', 'bitbucket', 'gitea']); test('valid signed deliveries do not consume the failure limit', function (string $provider) { $application = createApplicationWithWebhook(); @@ -156,20 +150,18 @@ describe('Manual Webhook Failed Authentication Rate Limiting', function () { expect(RateLimiter::attempts(manualWebhookFailureKey($provider)))->toBe(0); })->with(['github', 'gitlab', 'bitbucket', 'gitea']); - test('a signed delivery is blocked during lockout and processed after the failure window expires', function (string $provider) { + test('a signed delivery is processed while failed deliveries from the same proxy address lock the scope', function (string $provider) { Queue::fake(); $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook()); + // On self-hosted instances every delivery reaches PHP from the proxy container. + $proxyAddress = '10.0.1.5'; - lockOutManualWebhookRepository($this, $provider, $application); + for ($i = 0; $i < 31; $i++) { + sendManualWebhookPush($this, $provider, $application, validSignature: false, ip: $proxyAddress, extraServer: ['HTTP_X_FORWARDED_FOR' => '198.51.100.66']); + } + sendManualWebhookPush($this, $provider, $application, validSignature: false, ip: $proxyAddress)->assertStatus(429); - sendManualWebhookPush($this, $provider, $application) - ->assertStatus(429) - ->assertHeader('Retry-After'); - expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); - - $this->travel(61)->seconds(); - - $response = sendManualWebhookPush($this, $provider, $application); + $response = sendManualWebhookPush($this, $provider, $application, ip: $proxyAddress, extraServer: ['HTTP_X_FORWARDED_FOR' => '140.82.115.1']); $response->assertOk(); expect($response->getContent())->toContain('Deployment queued'); @@ -182,7 +174,7 @@ describe('Manual Webhook Failed Authentication Rate Limiting', function () { lockOutManualWebhookRepository($this, $provider, $application); $attempts = RateLimiter::attempts(manualWebhookFailureKey($provider)); - sendManualWebhookPush($this, $provider, $application)->assertStatus(429); + sendManualWebhookPush($this, $provider, $application)->assertOk(); sendManualWebhookPush($this, $provider, $application, validSignature: false) ->assertStatus(429) ->assertHeader('Retry-After') @@ -1353,8 +1345,7 @@ describe('Manual Webhook Repeated Failed Deliveries', function () { expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); - sendManualWebhookPush($this, 'gitlab', $application)->assertStatus(429); - expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + sendManualWebhookPush($this, 'gitlab', $application)->assertOk()->assertSee('Deployment queued'); }); test('repeated tokens do not extend the guess limit', function () { @@ -1435,8 +1426,7 @@ describe('Manual Webhook Repeated Failed Deliveries', function () { sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'guess-30')->assertStatus(429); expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); - sendManualWebhookPush($this, $provider, $application)->assertStatus(429); - expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + sendManualWebhookPush($this, $provider, $application)->assertOk()->assertSee('Deployment queued'); })->with(['github', 'bitbucket', 'gitea']); test('the same wrong signature for different payloads counts every payload', function (string $provider) { diff --git a/tests/Unit/ProxyPortValidationTest.php b/tests/Unit/ProxyPortValidationTest.php index ef2edcadaf..7a569de6fc 100644 --- a/tests/Unit/ProxyPortValidationTest.php +++ b/tests/Unit/ProxyPortValidationTest.php @@ -110,10 +110,28 @@ it('rejects invalid proxy port collection shapes', function (mixed $ports) { })->with([ 'scalar' => ['80:80'], 'map' => [['published' => 80]], - 'null' => [null], 'boolean' => [true], ]); +it('accepts an empty ports key like v4.3.23 did', function (string $configuration) { + expect(ProxyPortParser::fromConfiguration($configuration))->toBe([]) + ->and(ProxyPortParser::publishesOnlyUncheckedPorts($configuration))->toBeFalse(); +})->with([ + 'empty value' => ["services:\n traefik:\n image: traefik:v3.6\n ports:\n"], + 'explicit null' => ["services:\n caddy:\n ports: null\n"], +]); + +it('reports proxies whose published ports cannot be checked for conflicts', function (string $configuration, bool $expected) { + expect(ProxyPortParser::publishesOnlyUncheckedPorts($configuration))->toBe($expected); +})->with([ + 'variables without defaults' => ["services:\n traefik:\n ports: ['\${HTTP_PORT}:80', '\${HTTPS_PORT}:443']\n", true], + 'a concrete port' => ["services:\n traefik:\n ports: ['\${HTTP_PORT}:80', '443:443']\n", false], + 'only port ranges' => ["services:\n traefik:\n ports: ['10000-10100:10000-10100']\n", true], + 'a variable with a default' => ["services:\n traefik:\n ports: ['\${HTTP_PORT:-80}:80']\n", false], + 'no ports' => ["services:\n traefik:\n image: traefik:v3.6\n", false], + 'empty ports' => ["services:\n traefik:\n ports: []\n", false], +]); + it('accepts Docker Compose merge tags in proxy configurations', function (string $configuration, array $expected) { expect(ProxyPortParser::fromConfiguration($configuration))->toBe($expected); })->with([