From ddd84e5adc5d374524594f276190eb34b783938d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=CC=88mer=20Faruk=20S=CC=A7AHI=CC=87N?= Date: Wed, 27 May 2026 00:57:07 +0300 Subject: [PATCH 1/2] fix(git): write deploy key to per-deployment path, not root's id_rsa --- app/Jobs/ApplicationDeploymentJob.php | 7 +- app/Models/Application.php | 60 ++++++------ tests/Unit/DeployKeyDedicatedPathTest.php | 112 ++++++++++++++++++++++ 3 files changed, 149 insertions(+), 30 deletions(-) create mode 100644 tests/Unit/DeployKeyDedicatedPathTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 098cf7804..c85040c17 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2261,18 +2261,19 @@ private function check_git_if_build_needed() $private_key = data_get($this->application, 'private_key.private_key'); if ($private_key) { $private_key = base64_encode($private_key); + $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}"; $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'), ], [ - executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), + executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), ], [ - executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ], [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), + executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), 'hidden' => true, 'save' => 'git_commit_sha', ] diff --git a/app/Models/Application.php b/app/Models/Application.php index fd7f486b9..183d53858 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1343,6 +1343,7 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $branch = $this->git_branch; ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); $commands = collect([]); + $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}"; $base_command = 'git ls-remote'; if ($this->deploymentType() === 'source') { @@ -1396,19 +1397,20 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $private_key = base64_encode($private_key); $gitlabPort = $gitlabSource->custom_port ?? 22; $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); - $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'"; + $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'"; if ($exec_in_docker) { $commands = collect([ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), + executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ]); } else { $commands = collect([ + "trap 'rm -f {$customSshKeyLocation}' EXIT", 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } @@ -1454,19 +1456,20 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ // When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context // Replace ' with '\'' to safely escape within single-quoted bash strings $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); - $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'"; + $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'"; if ($exec_in_docker) { $commands = collect([ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), + executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ]); } else { $commands = collect([ + "trap 'rm -f {$customSshKeyLocation}' EXIT", 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } @@ -1507,6 +1510,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $branch = $this->git_branch; ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); $baseDir = $custom_base_dir ?? $this->generateBaseDir($deployment_uuid); + $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}"; // Escape shell arguments for safety to prevent command injection $escapedBranch = escapeshellarg($branch); @@ -1603,7 +1607,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $private_key = base64_encode($private_key); $gitlabPort = $gitlabSource->custom_port ?? 22; $escapedCustomRepository = escapeshellarg($customRepository); - $gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; + $gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\""; $git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; @@ -1613,14 +1617,15 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req if ($exec_in_docker) { $commands = collect([ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), + executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ]); } else { $commands = collect([ + "trap 'rm -f {$customSshKeyLocation}' EXIT", 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } @@ -1631,7 +1636,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } if ($exec_in_docker) { @@ -1674,7 +1679,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } $private_key = base64_encode($private_key); $escapedCustomRepository = escapeshellarg($customRepository); - $deployKeySshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; + $deployKeySshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\""; $git_clone_command_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; @@ -1684,14 +1689,15 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req if ($exec_in_docker) { $commands = collect([ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), + executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ]); } else { $commands = collect([ + "trap 'rm -f {$customSshKeyLocation}' EXIT", 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } if ($pull_request_id !== 0) { @@ -1702,7 +1708,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1710,14 +1716,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" ".$this->buildGitCheckoutCommand($commit); } } @@ -1747,7 +1753,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1755,14 +1761,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" ".$this->buildGitCheckoutCommand($commit); } } diff --git a/tests/Unit/DeployKeyDedicatedPathTest.php b/tests/Unit/DeployKeyDedicatedPathTest.php new file mode 100644 index 000000000..9fb4e1b1d --- /dev/null +++ b/tests/Unit/DeployKeyDedicatedPathTest.php @@ -0,0 +1,112 @@ +) instead of the shared + * /root/.ssh/id_rsa, so it can neither overwrite the server root's own key nor race with other + * concurrent operations on the same host. `-o IdentitiesOnly=yes` makes ssh offer only that key. + * On the host path an EXIT trap removes the key when the shell finishes; the docker path runs in an + * ephemeral container, so it needs no cleanup. + */ +$keyPath = '/root/.ssh/id_rsa_coolify_test-deployment-uuid'; + +it('writes a deploy key to a per-deployment path and cleans it up for ls-remote on the host', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('deploy_key'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey); + + $result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false); + + expect($result['commands']) + ->toContain("tee {$keyPath}") + ->toContain("-i {$keyPath} -o IdentitiesOnly=yes") + ->toContain("trap 'rm -f {$keyPath}' EXIT") // removed when the shell exits + ->not->toContain('tee /root/.ssh/id_rsa >'); // never overwrites the host root's own key +}); + +it('writes a deploy key to a per-deployment path for ls-remote inside docker without a trap', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('deploy_key'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey); + + $result = $application->generateGitLsRemoteCommands('test-deployment-uuid', true); + + expect($result['commands']) + ->toContain("tee {$keyPath}") + ->toContain("-i {$keyPath} -o IdentitiesOnly=yes") + ->not->toContain('trap ') // ephemeral container, no cleanup needed + ->not->toContain('tee /root/.ssh/id_rsa >'); +}); + +it('writes a GitLab source private key to a per-deployment path with cleanup on the host', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1); + $gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false); + + expect($result['commands']) + ->toContain("tee {$keyPath}") + ->toContain("-i {$keyPath} -o IdentitiesOnly=yes") + ->toContain("trap 'rm -f {$keyPath}' EXIT") + ->not->toContain('tee /root/.ssh/id_rsa >'); +}); + +it('writes a deploy key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $settings = Mockery::mock(ApplicationSetting::class)->makePartial(); + $settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('deploy_key'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD'); + + // exec_in_docker = false → the loadComposeFile / host clone path + $result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false); + + expect($result['commands']) + ->toContain("tee {$keyPath}") + ->toContain("-i {$keyPath} -o IdentitiesOnly=yes") + ->toContain("trap 'rm -f {$keyPath}' EXIT") + ->not->toContain('tee /root/.ssh/id_rsa >'); +}); From e39a9ad8274badb55eba5ccfacbe9a1918fbbea1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:39:05 +0200 Subject: [PATCH 2/2] fix(git): use deploy key path for PR fetches --- app/Models/Application.php | 2 +- tests/Unit/DeployKeyDedicatedPathTest.php | 77 +++++++++++++++++++++++ tests/Unit/GitSubmoduleCredentialTest.php | 2 +- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index 809b5336a..b2f852f15 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1801,7 +1801,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); } $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); - $otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes"; + $otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; if ($pull_request_id !== 0) { $gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git'; diff --git a/tests/Unit/DeployKeyDedicatedPathTest.php b/tests/Unit/DeployKeyDedicatedPathTest.php index 9fb4e1b1d..a3373f42a 100644 --- a/tests/Unit/DeployKeyDedicatedPathTest.php +++ b/tests/Unit/DeployKeyDedicatedPathTest.php @@ -110,3 +110,80 @@ ->toContain("trap 'rm -f {$keyPath}' EXIT") ->not->toContain('tee /root/.ssh/id_rsa >'); }); + +it('writes a GitLab source private key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey); + $gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22); + + $settings = Mockery::mock(ApplicationSetting::class)->makePartial(); + $settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->source = $gitlabSource; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD'); + + $result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false); + + expect($result['commands']) + ->toContain("tee {$keyPath}") + ->toContain("-i {$keyPath} -o IdentitiesOnly=yes") + ->toContain("trap 'rm -f {$keyPath}' EXIT") + ->not->toContain('tee /root/.ssh/id_rsa >'); +}); + +it('uses the per-deployment deploy key for pull request fetches', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $settings = Mockery::mock(ApplicationSetting::class)->makePartial(); + $settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('deploy_key'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@github.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD'); + + $result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false); + + expect($result['commands']) + ->toContain("GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$keyPath} -o IdentitiesOnly=yes\" git fetch origin pull/123/head:pr-123-coolify") + ->not->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify'); +}); + +it('does not force a missing per-deployment key for other repository pull request fetches', function () use ($keyPath) { + $settings = Mockery::mock(ApplicationSetting::class)->makePartial(); + $settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('other'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@github.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD'); + + $result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false); + + expect($result['commands']) + ->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify') + ->not->toContain($keyPath); +}); diff --git a/tests/Unit/GitSubmoduleCredentialTest.php b/tests/Unit/GitSubmoduleCredentialTest.php index 5ac5c501a..d22e1ad4c 100644 --- a/tests/Unit/GitSubmoduleCredentialTest.php +++ b/tests/Unit/GitSubmoduleCredentialTest.php @@ -156,7 +156,7 @@ exec_in_docker: false, ); - $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa'; + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa_coolify_test-uuid -o IdentitiesOnly=yes'; expect($result['commands']) ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify')