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([