From 35a61102522b300fb3edde1271471ddbc9183543 Mon Sep 17 00:00:00 2001 From: Jono Date: Tue, 17 Feb 2026 15:30:49 -0800 Subject: [PATCH 01/19] Dont ignore "force https" pref when using docker compose --- app/Models/ServiceApplication.php | 5 +++++ app/Models/ServiceDatabase.php | 5 +++++ bootstrap/helpers/parsers.php | 16 ++++++++-------- bootstrap/helpers/shared.php | 8 ++++---- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 7b8b46812..cbd02daa6 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -81,6 +81,11 @@ public function isGzipEnabled() return data_get($this, 'is_gzip_enabled', true); } + public function isForceHttpsEnabled() + { + return data_get($this, 'is_force_https_enabled', true); + } + public function type() { return 'service'; diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index f6a39cfe4..aee71295a 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -80,6 +80,11 @@ public function isGzipEnabled() return data_get($this, 'is_gzip_enabled', true); } + public function isForceHttpsEnabled() + { + return data_get($this, 'is_force_https_enabled', true); + } + public function type() { return 'service'; diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 43ba58e59..45125bce7 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1233,7 +1233,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1246,7 +1246,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1260,7 +1260,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1271,7 +1271,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2329,7 +2329,7 @@ function serviceParser(Service $resource): Collection $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2342,7 +2342,7 @@ function serviceParser(Service $resource): Collection network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2356,7 +2356,7 @@ function serviceParser(Service $resource): Collection $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2367,7 +2367,7 @@ function serviceParser(Service $resource): Collection network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 4372ff955..2a7d5cbb0 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1933,7 +1933,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1946,7 +1946,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal network: $resource->destination->network, uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1959,7 +1959,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1970,7 +1970,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal network: $resource->destination->network, uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), From c1951726c0a9afbf81a10473de124ad8d12d7ed5 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:42:28 +0530 Subject: [PATCH 02/19] feat(service): disable pterodactyl panel and pterodactyl wings The template is using latest version of pterodactyl and the issue is the db migration fails for new users but works fine for existing deployments. We cannot revert the template to previous version because the current latest version addresses few CVEs so it's better to disable this template for now --- templates/compose/pterodactyl-panel.yaml | 3 ++- templates/compose/pterodactyl-with-wings.yaml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/templates/compose/pterodactyl-panel.yaml b/templates/compose/pterodactyl-panel.yaml index 9a3f6c779..c86d9d468 100644 --- a/templates/compose/pterodactyl-panel.yaml +++ b/templates/compose/pterodactyl-panel.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://pterodactyl.io/ # slogan: Pterodactyl is a free, open-source game server management panel # category: media @@ -102,4 +103,4 @@ services: - MAIL_PORT=$MAIL_PORT - MAIL_USERNAME=$MAIL_USERNAME - MAIL_PASSWORD=$MAIL_PASSWORD - - MAIL_ENCRYPTION=$MAIL_ENCRYPTION + - MAIL_ENCRYPTION=$MAIL_ENCRYPTION \ No newline at end of file diff --git a/templates/compose/pterodactyl-with-wings.yaml b/templates/compose/pterodactyl-with-wings.yaml index 6e1e3614c..20465a139 100644 --- a/templates/compose/pterodactyl-with-wings.yaml +++ b/templates/compose/pterodactyl-with-wings.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://pterodactyl.io/ # slogan: Pterodactyl is a free, open-source game server management panel # category: media From 76d3709163e7d1625d008a2881eb375234ab998a Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:17:23 +0530 Subject: [PATCH 03/19] feat(service): upgrade beszel and beszel-agent to v0.18 --- templates/compose/beszel-agent.yaml | 21 +++++++++++++++---- templates/compose/beszel.yaml | 32 +++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/templates/compose/beszel-agent.yaml b/templates/compose/beszel-agent.yaml index a318f4702..5d0b4fecc 100644 --- a/templates/compose/beszel-agent.yaml +++ b/templates/compose/beszel-agent.yaml @@ -6,13 +6,26 @@ services: beszel-agent: - image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: + # Required - LISTEN=/beszel_socket/beszel.sock - - HUB_URL=${HUB_URL?} - - 'TOKEN=${TOKEN?}' - - 'KEY=${KEY?}' + - HUB_URL=$SERVICE_URL_BESZEL + - TOKEN=${TOKEN} # From hub token settings + - KEY=${KEY} # SSH public key(s) from hub + # Optional + - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH + - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level + - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring + - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name volumes: - beszel_agent_data:/var/lib/beszel-agent - beszel_socket:/beszel_socket - '/var/run/docker.sock:/var/run/docker.sock:ro' + healthcheck: + test: ['CMD', '/agent', 'health'] + interval: 60s + timeout: 20s + retries: 10 + start_period: 5s \ No newline at end of file diff --git a/templates/compose/beszel.yaml b/templates/compose/beszel.yaml index cba11e4bb..bc68c1825 100644 --- a/templates/compose/beszel.yaml +++ b/templates/compose/beszel.yaml @@ -9,21 +9,41 @@ # Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI) services: beszel: - image: 'henrygd/beszel:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel:0.18.4' # Released on 21 Feb 2026 environment: - SERVICE_URL_BESZEL_8090 + - CONTAINER_DETAILS=${CONTAINER_DETAILS:-true} + - SHARE_ALL_SYSTEMS=${SHARE_ALL_SYSTEMS:-false} volumes: - 'beszel_data:/beszel_data' - 'beszel_socket:/beszel_socket' + healthcheck: + test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090'] + interval: 30s + timeout: 20s + retries: 10 + start_period: 5s beszel-agent: - image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: + # Required - LISTEN=/beszel_socket/beszel.sock - - HUB_URL=http://beszel:8090 - - 'TOKEN=${TOKEN}' - - 'KEY=${KEY}' + - HUB_URL=$SERVICE_URL_BESZEL + - TOKEN=${TOKEN} # From hub token settings + - KEY=${KEY} # SSH public key(s) from hub + # Optional + - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH + - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level + - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring + - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name volumes: - beszel_agent_data:/var/lib/beszel-agent - beszel_socket:/beszel_socket - '/var/run/docker.sock:/var/run/docker.sock:ro' - + healthcheck: + test: ['CMD', '/agent', 'health'] + interval: 60s + timeout: 20s + retries: 10 + start_period: 5s \ No newline at end of file From 73170fdd33783337a91b27191f126cbd5c61faed Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:12:10 +0100 Subject: [PATCH 04/19] chore: prepare for PR --- .../Api/ApplicationsController.php | 4 - app/Http/Controllers/Api/DeployController.php | 4 + app/Jobs/ApplicationDeploymentJob.php | 18 +- app/Livewire/Project/Application/General.php | 4 +- .../Project/New/GithubPrivateRepository.php | 2 + .../New/GithubPrivateRepositoryDeployKey.php | 2 + .../Project/New/PublicGitRepository.php | 4 +- bootstrap/helpers/api.php | 4 +- database/seeders/ApplicationSeeder.php | 2 +- routes/api.php | 18 +- .../Feature/CommandInjectionSecurityTest.php | 276 ++++++++++++++++++ 11 files changed, 315 insertions(+), 23 deletions(-) create mode 100644 tests/Feature/CommandInjectionSecurityTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 57bcc13f6..256308afd 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1101,7 +1101,6 @@ private function create_application(Request $request, $type) 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1297,7 +1296,6 @@ private function create_application(Request $request, $type) 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'github_app_uuid' => 'string|required', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1525,7 +1523,6 @@ private function create_application(Request $request, $type) 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'private_key_uuid' => 'string|required', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -2470,7 +2467,6 @@ public function update_by_uuid(Request $request) 'description' => 'string|nullable', 'static_image' => 'string', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index baff3ec4f..a21940257 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -127,6 +127,10 @@ public function deployment_by_uuid(Request $request) if (! $deployment) { return response()->json(['message' => 'Deployment not found.'], 404); } + $application = $deployment->application; + if (! $application || data_get($application->team(), 'id') !== $teamId) { + return response()->json(['message' => 'Deployment not found.'], 404); + } return response()->json($this->removeSensitiveData($deployment)); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index eaee7e221..ee1f6d810 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -251,7 +251,7 @@ public function __construct(public int $application_deployment_queue_id) } if ($this->application->build_pack === 'dockerfile') { if (data_get($this->application, 'dockerfile_location')) { - $this->dockerfile_location = $this->application->dockerfile_location; + $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location'); } } } @@ -571,7 +571,7 @@ private function deploy_dockerimage_buildpack() private function deploy_docker_compose_buildpack() { if (data_get($this->application, 'docker_compose_location')) { - $this->docker_compose_location = $this->application->docker_compose_location; + $this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location'); } if (data_get($this->application, 'docker_compose_custom_start_command')) { $this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command; @@ -831,7 +831,7 @@ private function deploy_dockerfile_buildpack() $this->server = $this->build_server; } if (data_get($this->application, 'dockerfile_location')) { - $this->dockerfile_location = $this->application->dockerfile_location; + $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location'); } $this->prepare_builder_image(); $this->check_git_if_build_needed(); @@ -3879,6 +3879,18 @@ private function add_build_secrets_to_compose($composeFile) return $composeFile; } + private function validatePathField(string $value, string $fieldName): string + { + if (! preg_match('/^\/[a-zA-Z0-9._\-\/]+$/', $value)) { + throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters."); + } + if (str_contains($value, '..')) { + throw new \RuntimeException("Invalid {$fieldName}: path traversal detected."); + } + + return $value; + } + private function run_pre_deployment_command() { if (empty($this->application->pre_deployment_command)) { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index b7c17fcc3..008bd3905 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -73,7 +73,7 @@ class General extends Component #[Validate(['string', 'nullable'])] public ?string $dockerfile = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] public ?string $dockerfileLocation = null; #[Validate(['string', 'nullable'])] @@ -85,7 +85,7 @@ class General extends Component #[Validate(['string', 'nullable'])] public ?string $dockerRegistryImageTag = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] public ?string $dockerComposeLocation = null; #[Validate(['string', 'nullable'])] diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 6acb17f82..1bb276b89 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -163,10 +163,12 @@ public function submit() 'selected_repository_owner' => $this->selected_repository_owner, 'selected_repository_repo' => $this->selected_repository_repo, 'selected_branch_name' => $this->selected_branch_name, + 'docker_compose_location' => $this->docker_compose_location, ], [ 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]); if ($validator->fails()) { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 77b106200..f52c01e91 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -64,6 +64,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]; protected function rules() @@ -75,6 +76,7 @@ protected function rules() 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]; } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 2fffff6b9..a08c448dd 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -70,7 +70,7 @@ class PublicGitRepository extends Component 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => 'nullable|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]; protected function rules() @@ -82,7 +82,7 @@ protected function rules() 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => 'nullable|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'git_branch' => ['required', 'string', new ValidGitBranch], ]; } diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index d5c2c996b..5674d37f6 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -132,8 +132,8 @@ function sharedDataApplications() 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', - 'dockerfile_location' => 'string|nullable', - 'docker_compose_location' => 'string', + 'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', 'docker_compose_custom_start_command' => 'string|nullable', diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index f5a00fe15..18ffbe166 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -21,7 +21,7 @@ public function run(): void 'git_repository' => 'coollabsio/coolify-examples', 'git_branch' => 'v4.x', 'base_directory' => '/docker-compose', - 'docker_compose_location' => 'docker-compose-test.yaml', + 'docker_compose_location' => '/docker-compose-test.yaml', 'build_pack' => 'dockercompose', 'ports_exposes' => '80', 'environment_id' => 1, diff --git a/routes/api.php b/routes/api.php index c39f22c02..56f984245 100644 --- a/routes/api.php +++ b/routes/api.php @@ -121,9 +121,9 @@ Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); - Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']); @@ -152,9 +152,9 @@ Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']); Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']); @@ -169,9 +169,9 @@ Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php new file mode 100644 index 000000000..47e9f3b35 --- /dev/null +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -0,0 +1,276 @@ +getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile; echo pwned', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects backtick injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile`whoami`', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects dollar sign variable expansion', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile$(whoami)', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects pipe injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile | cat /etc/passwd', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects ampersand injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile && env', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects path traversal', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/../../../etc/passwd', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'path traversal detected'); + }); + + test('allows valid simple path', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/Dockerfile', 'dockerfile_location')) + ->toBe('/Dockerfile'); + }); + + test('allows valid nested path with dots and hyphens', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/docker/Dockerfile.prod', 'dockerfile_location')) + ->toBe('/docker/Dockerfile.prod'); + }); + + test('allows valid compose file path', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/docker-compose.prod.yml', 'docker_compose_location')) + ->toBe('/docker-compose.prod.yml'); + }); +}); + +describe('API validation rules for path fields', function () { + test('dockerfile_location validation rejects shell metacharacters', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => '/Dockerfile; echo pwned; #'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('dockerfile_location validation allows valid paths', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => '/docker/Dockerfile.prod'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeFalse(); + }); + + test('docker_compose_location validation rejects shell metacharacters', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => '/docker-compose.yml; env; #'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('docker_compose_location validation allows valid paths', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => '/docker/docker-compose.prod.yml'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeFalse(); + }); +}); + +describe('sharedDataApplications rules survive array_merge in controller', function () { + test('docker_compose_location safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + // Simulate what ApplicationsController does: array_merge(shared, local) + // After our fix, local no longer contains docker_compose_location, + // so the shared regex rule must survive + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + // The merged rules for docker_compose_location should be the safe regex, not just 'string' + expect($merged['docker_compose_location'])->toBeArray(); + expect($merged['docker_compose_location'])->toContain('regex:/^\/[a-zA-Z0-9._\-\/]+$/'); + }); +}); + +describe('path fields require leading slash', function () { + test('dockerfile_location without leading slash is rejected by API rules', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => 'Dockerfile'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('docker_compose_location without leading slash is rejected by API rules', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => 'docker-compose.yaml'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('deployment job rejects path without leading slash', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'docker-compose.yaml', 'docker_compose_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); +}); + +describe('API route middleware for deploy actions', function () { + test('application start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $route = $routes->getByAction('App\Http\Controllers\Api\ApplicationsController@action_deploy'); + + expect($route)->not->toBeNull(); + $middleware = $route->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + expect($middleware)->not->toContain('api.ability:write'); + }); + + test('application restart route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'restart')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('application stop route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'stop')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('database start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'databases') && str_contains($route->uri(), 'start')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('service start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'services') && str_contains($route->uri(), 'start')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); +}); From 16e85e27e83b6be5e321d1dafe1c61cb1a45f8ac Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:14:44 +0100 Subject: [PATCH 05/19] fix(service): always enable force https labels Force HTTPS routing labels in parser helpers and remove per-service toggles now that the preference is no longer honored. --- app/Models/ServiceApplication.php | 5 ----- app/Models/ServiceDatabase.php | 5 ----- bootstrap/helpers/parsers.php | 8 ++++---- bootstrap/helpers/shared.php | 8 ++++---- 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index cbd02daa6..7b8b46812 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -81,11 +81,6 @@ public function isGzipEnabled() return data_get($this, 'is_gzip_enabled', true); } - public function isForceHttpsEnabled() - { - return data_get($this, 'is_force_https_enabled', true); - } - public function type() { return 'service'; diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index aee71295a..f6a39cfe4 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -80,11 +80,6 @@ public function isGzipEnabled() return data_get($this, 'is_gzip_enabled', true); } - public function isForceHttpsEnabled() - { - return data_get($this, 'is_force_https_enabled', true); - } - public function type() { return 'service'; diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 45125bce7..53060d28f 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -2329,7 +2329,7 @@ function serviceParser(Service $resource): Collection $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: $originalResource->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2342,7 +2342,7 @@ function serviceParser(Service $resource): Collection network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: $originalResource->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2356,7 +2356,7 @@ function serviceParser(Service $resource): Collection $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: $originalResource->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2367,7 +2367,7 @@ function serviceParser(Service $resource): Collection network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: $originalResource->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 2a7d5cbb0..4372ff955 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1933,7 +1933,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: $savedService->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1946,7 +1946,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal network: $resource->destination->network, uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: $savedService->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1959,7 +1959,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: $savedService->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1970,7 +1970,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal network: $resource->destination->network, uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: $savedService->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), From ffb408f2146d9503ba5bf9952c8437c4546c2b13 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:18:50 +0100 Subject: [PATCH 06/19] Create docker-compose-maxio.dev.yml --- docker-compose-maxio.dev.yml | 209 +++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docker-compose-maxio.dev.yml diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml new file mode 100644 index 000000000..e2650fb7b --- /dev/null +++ b/docker-compose-maxio.dev.yml @@ -0,0 +1,209 @@ +services: + coolify: + image: coolify:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/development/Dockerfile + args: + - USER_ID=${USERID:-1000} + - GROUP_ID=${GROUPID:-1000} + ports: + - "${APP_PORT:-8000}:8080" + environment: + AUTORUN_ENABLED: false + PUSHER_HOST: "${PUSHER_HOST}" + PUSHER_PORT: "${PUSHER_PORT}" + PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" + PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" + PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + healthcheck: + test: curl -sf http://127.0.0.1:8080/api/health || exit 1 + interval: 5s + retries: 10 + timeout: 2s + volumes: + - .:/var/www/html/:cached + - dev_backups_data:/var/www/html/storage/app/backups + networks: + - coolify + postgres: + pull_policy: always + ports: + - "${FORWARD_DB_PORT:-5432}:5432" + env_file: + - .env + environment: + POSTGRES_USER: "${DB_USERNAME:-coolify}" + POSTGRES_PASSWORD: "${DB_PASSWORD:-password}" + POSTGRES_DB: "${DB_DATABASE:-coolify}" + POSTGRES_HOST_AUTH_METHOD: "trust" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] + interval: 5s + retries: 10 + timeout: 2s + volumes: + - dev_postgres_data:/var/lib/postgresql/data + redis: + pull_policy: always + ports: + - "${FORWARD_REDIS_PORT:-6379}:6379" + env_file: + - .env + healthcheck: + test: redis-cli ping + interval: 5s + retries: 10 + timeout: 2s + volumes: + - dev_redis_data:/data + soketi: + image: coolify-realtime:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/coolify-realtime/Dockerfile + env_file: + - .env + ports: + - "${FORWARD_SOKETI_PORT:-6001}:6001" + - "6002:6002" + volumes: + - ./storage:/var/www/html/storage + - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js + environment: + SOKETI_DEBUG: "false" + SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" + SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + healthcheck: + test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ] + interval: 5s + retries: 10 + timeout: 2s + entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"] + vite: + image: node:24-alpine + pull_policy: always + container_name: coolify-vite + working_dir: /var/www/html + environment: + VITE_HOST: "${VITE_HOST:-localhost}" + VITE_PORT: "${VITE_PORT:-5173}" + ports: + - "${VITE_PORT:-5173}:${VITE_PORT:-5173}" + volumes: + - .:/var/www/html/:cached + command: sh -c "npm install && npm run dev" + networks: + - coolify + testing-host: + image: coolify-testing-host:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/testing-host/Dockerfile + init: true + container_name: coolify-testing-host + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - dev_coolify_data:/data/coolify + - dev_backups_data:/data/coolify/backups + - dev_postgres_data:/data/coolify/_volumes/database + - dev_redis_data:/data/coolify/_volumes/redis + - dev_minio_data:/data/coolify/_volumes/minio + networks: + - coolify + mailpit: + image: axllent/mailpit:latest + pull_policy: always + container_name: coolify-mail + ports: + - "${FORWARD_MAILPIT_PORT:-1025}:1025" + - "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025" + networks: + - coolify + # maxio: + # image: ghcr.io/coollabsio/maxio + # pull_policy: always + # container_name: coolify-maxio + # ports: + # - "${FORWARD_MAXIO_PORT:-9000}:9000" + # environment: + # MAXIO_ACCESS_KEY: "${MAXIO_ACCESS_KEY:-maxioadmin}" + # MAXIO_SECRET_KEY: "${MAXIO_SECRET_KEY:-maxioadmin}" + # volumes: + # - dev_maxio_data:/data + # networks: + # - coolify + minio: + image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025 + pull_policy: always + container_name: coolify-minio + command: server /data --console-address ":9001" + ports: + - "${FORWARD_MINIO_PORT:-9000}:9000" + - "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001" + environment: + MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}" + MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}" + volumes: + - dev_minio_data:/data + - dev_maxio_data:/data + networks: + - coolify + # maxio-init: + # image: minio/mc:latest + # pull_policy: always + # container_name: coolify-maxio-init + # restart: no + # depends_on: + # - maxio + # entrypoint: > + # /bin/sh -c " + # echo 'Waiting for MaxIO to be ready...'; + # until mc alias set local http://coolify-maxio:9000 maxioadmin maxioadmin 2>/dev/null; do + # echo 'MaxIO not ready yet, waiting...'; + # sleep 2; + # done; + # echo 'MaxIO is ready, creating bucket if needed...'; + # mc mb local/local --ignore-existing; + # echo 'MaxIO initialization complete - bucket local is ready'; + # " + # networks: + # - coolify + minio-init: + image: minio/mc:latest + pull_policy: always + container_name: coolify-minio-init + restart: no + depends_on: + - minio + entrypoint: > + /bin/sh -c " + echo 'Waiting for MinIO to be ready...'; + until mc alias set local http://coolify-minio:9000 minioadmin minioadmin 2>/dev/null; do + echo 'MinIO not ready yet, waiting...'; + sleep 2; + done; + echo 'MinIO is ready, creating bucket if needed...'; + mc mb local/local --ignore-existing; + echo 'MinIO initialization complete - bucket local is ready'; + " + networks: + - coolify + +volumes: + dev_backups_data: + dev_postgres_data: + dev_redis_data: + dev_coolify_data: + dev_minio_data: + dev_maxio_data: + +networks: + coolify: + name: coolify + external: false From cb0f5cc812d81a812da12d958cac8a6ae285513f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:19:57 +0100 Subject: [PATCH 07/19] chore: prepare for PR --- app/Jobs/ScheduledJobManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index c9dc20af1..d69585788 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -104,7 +104,7 @@ public function handle(): void Log::channel('scheduled')->info('ScheduledJobManager completed', [ 'execution_time' => $this->executionTime->toIso8601String(), - 'duration_ms' => Carbon::now()->diffInMilliseconds($this->executionTime), + 'duration_ms' => $this->executionTime->diffInMilliseconds(Carbon::now()), 'dispatched' => $this->dispatchedCount, 'skipped' => $this->skippedCount, ]); From bf51ed905fd296d6a7bcd1f2a6203a40b61790be Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:02:06 +0100 Subject: [PATCH 08/19] chore: prepare for PR --- app/Models/Team.php | 3 +- tests/Feature/TeamNotificationCheckTest.php | 52 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/TeamNotificationCheckTest.php diff --git a/app/Models/Team.php b/app/Models/Team.php index 5cb186942..e32526169 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -191,7 +191,8 @@ public function isAnyNotificationEnabled() $this->getNotificationSettings('discord')?->isEnabled() || $this->getNotificationSettings('slack')?->isEnabled() || $this->getNotificationSettings('telegram')?->isEnabled() || - $this->getNotificationSettings('pushover')?->isEnabled(); + $this->getNotificationSettings('pushover')?->isEnabled() || + $this->getNotificationSettings('webhook')?->isEnabled(); } public function subscriptionEnded() diff --git a/tests/Feature/TeamNotificationCheckTest.php b/tests/Feature/TeamNotificationCheckTest.php new file mode 100644 index 000000000..2a39b020e --- /dev/null +++ b/tests/Feature/TeamNotificationCheckTest.php @@ -0,0 +1,52 @@ +team = Team::factory()->create(); +}); + +describe('isAnyNotificationEnabled', function () { + test('returns false when no notifications are enabled', function () { + expect($this->team->isAnyNotificationEnabled())->toBeFalse(); + }); + + test('returns true when email notifications are enabled', function () { + $this->team->emailNotificationSettings->update(['smtp_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when discord notifications are enabled', function () { + $this->team->discordNotificationSettings->update(['discord_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when slack notifications are enabled', function () { + $this->team->slackNotificationSettings->update(['slack_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when telegram notifications are enabled', function () { + $this->team->telegramNotificationSettings->update(['telegram_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when pushover notifications are enabled', function () { + $this->team->pushoverNotificationSettings->update(['pushover_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when webhook notifications are enabled', function () { + $this->team->webhookNotificationSettings->update(['webhook_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); +}); From 61a54afe2be25ac49501196927a96783370a07a2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:23:12 +0100 Subject: [PATCH 09/19] fix(service): resolve team lookup via service relationship Update service application/database team accessors to traverse the service relation chain and add coverage to prevent null team regressions. --- app/Models/ServiceApplication.php | 2 +- app/Models/ServiceDatabase.php | 2 +- tests/Feature/ServiceDatabaseTeamTest.php | 77 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/ServiceDatabaseTeamTest.php diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 7b8b46812..4bf78085e 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -88,7 +88,7 @@ public function type() public function team() { - return data_get($this, 'environment.project.team'); + return data_get($this, 'service.environment.project.team'); } public function workdir() diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index f6a39cfe4..7b0abe59e 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -124,7 +124,7 @@ public function getServiceDatabaseUrl() public function team() { - return data_get($this, 'environment.project.team'); + return data_get($this, 'service.environment.project.team'); } public function workdir() diff --git a/tests/Feature/ServiceDatabaseTeamTest.php b/tests/Feature/ServiceDatabaseTeamTest.php new file mode 100644 index 000000000..97bb0fd2a --- /dev/null +++ b/tests/Feature/ServiceDatabaseTeamTest.php @@ -0,0 +1,77 @@ +create(); + + $project = Project::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $team->id, + ]); + + $environment = Environment::create([ + 'name' => 'test-env-'.Illuminate\Support\Str::random(8), + 'project_id' => $project->id, + ]); + + $service = Service::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase', + 'environment_id' => $environment->id, + 'destination_id' => 1, + 'destination_type' => 'App\Models\StandaloneDocker', + 'docker_compose_raw' => 'version: "3"', + ]); + + $serviceDatabase = ServiceDatabase::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase-db', + 'service_id' => $service->id, + ]); + + expect($serviceDatabase->team())->not->toBeNull() + ->and($serviceDatabase->team()->id)->toBe($team->id); +}); + +it('returns the correct team for ServiceApplication through the service relationship chain', function () { + $team = Team::factory()->create(); + + $project = Project::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $team->id, + ]); + + $environment = Environment::create([ + 'name' => 'test-env-'.Illuminate\Support\Str::random(8), + 'project_id' => $project->id, + ]); + + $service = Service::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase', + 'environment_id' => $environment->id, + 'destination_id' => 1, + 'destination_type' => 'App\Models\StandaloneDocker', + 'docker_compose_raw' => 'version: "3"', + ]); + + $serviceApplication = ServiceApplication::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase-studio', + 'service_id' => $service->id, + ]); + + expect($serviceApplication->team())->not->toBeNull() + ->and($serviceApplication->team()->id)->toBe($team->id); +}); From b7b0dfedddb0a7237cdc0e53b7b7dc5ef4b21ca1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:24:49 +0100 Subject: [PATCH 10/19] chore: prepare for PR --- config/horizon.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/horizon.php b/config/horizon.php index cdabcb1e8..0423f1549 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -184,13 +184,13 @@ 'connection' => 'redis', 'balance' => env('HORIZON_BALANCE', 'false'), 'queue' => env('HORIZON_QUEUES', 'high,default'), - 'maxTime' => 3600, + 'maxTime' => env('HORIZON_MAX_TIME', 0), 'maxJobs' => 400, 'memory' => 128, 'tries' => 1, 'nice' => 0, 'sleep' => 3, - 'timeout' => 3600, + 'timeout' => env('HORIZON_TIMEOUT', 36000), ], ], From 76a6960f4448636786b8ca5bfbbe507148f1811f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:26:01 +0100 Subject: [PATCH 11/19] chore: prepare for PR --- app/Actions/Database/StartKeydb.php | 3 ++ app/Actions/Database/StartRedis.php | 3 ++ tests/Unit/StartKeydbConfigPermissionTest.php | 52 ++++++++++++++++++ tests/Unit/StartRedisConfigPermissionTest.php | 53 +++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 tests/Unit/StartKeydbConfigPermissionTest.php create mode 100644 tests/Unit/StartRedisConfigPermissionTest.php diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 58f3cda4e..fe80a7d54 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -207,6 +207,9 @@ public function handle(StandaloneKeydb $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } + if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) { + $this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf"; + } $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 4e4f3ce53..70df91054 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -204,6 +204,9 @@ public function handle(StandaloneRedis $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } + if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) { + $this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf"; + } $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; diff --git a/tests/Unit/StartKeydbConfigPermissionTest.php b/tests/Unit/StartKeydbConfigPermissionTest.php new file mode 100644 index 000000000..dca3b0e8c --- /dev/null +++ b/tests/Unit/StartKeydbConfigPermissionTest.php @@ -0,0 +1,52 @@ +configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('maxmemory 2gb'); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/keydb.conf'); +}); + +test('keydb config chown command is not added when keydb_conf is null', function () { + $action = new StartKeydb; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(null); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); + +test('keydb config chown command is not added when keydb_conf is empty', function () { + $action = new StartKeydb; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(''); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); diff --git a/tests/Unit/StartRedisConfigPermissionTest.php b/tests/Unit/StartRedisConfigPermissionTest.php new file mode 100644 index 000000000..77574287e --- /dev/null +++ b/tests/Unit/StartRedisConfigPermissionTest.php @@ -0,0 +1,53 @@ +configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('maxmemory 2gb'); + $action->database = $database; + + // Simulate the chown logic from handle() + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/redis.conf'); +}); + +test('redis config chown command is not added when redis_conf is null', function () { + $action = new StartRedis; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(null); + $action->database = $database; + + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); + +test('redis config chown command is not added when redis_conf is empty', function () { + $action = new StartRedis; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(''); + $action->database = $database; + + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); From d71d91d63ea9ef5a2b1fb86f7b7baaf5ea036d0d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:47:26 +0100 Subject: [PATCH 12/19] fix(version): update coolify version to 4.0.0-beta.464 and nightly version to 4.0.0-beta.465 --- config/constants.php | 2 +- versions.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 0b404fe9d..be41c4618 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.463', + 'version' => '4.0.0-beta.464', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/versions.json b/versions.json index 1ce790111..7409fbc42 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.463" + "version": "4.0.0-beta.464" }, "nightly": { - "version": "4.0.0-beta.464" + "version": "4.0.0-beta.465" }, "helper": { "version": "1.0.12" From 620da191b1244be707949cc27a21ef74d26320a3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:15:13 +0100 Subject: [PATCH 13/19] chore: prepare for PR --- app/Models/Application.php | 2 +- tests/Unit/ApplicationDeploymentTypeTest.php | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/ApplicationDeploymentTypeTest.php diff --git a/app/Models/Application.php b/app/Models/Application.php index d6c222a97..28ef79078 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -990,7 +990,7 @@ public function deploymentType() if (isDev() && data_get($this, 'private_key_id') === 0) { return 'deploy_key'; } - if (data_get($this, 'private_key_id')) { + if (! is_null(data_get($this, 'private_key_id'))) { return 'deploy_key'; } elseif (data_get($this, 'source')) { return 'source'; diff --git a/tests/Unit/ApplicationDeploymentTypeTest.php b/tests/Unit/ApplicationDeploymentTypeTest.php new file mode 100644 index 000000000..d240181f1 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentTypeTest.php @@ -0,0 +1,11 @@ +private_key_id = 0; + $application->source = null; + + expect($application->deploymentType())->toBe('deploy_key'); +}); From 6cacd2f0ffaf31dd97d987382c843da0d3b54a07 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:17:15 +0100 Subject: [PATCH 14/19] chore: prepare for PR --- resources/views/livewire/project/application/heading.blade.php | 2 +- resources/views/livewire/project/service/heading.blade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index 96e5d9770..4af466fc5 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -1,7 +1,7 @@