diff --git a/app/Models/Application.php b/app/Models/Application.php index 34ab4141e..7b46b6f3d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1163,14 +1163,15 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $base_command = "{$base_command} {$escapedRepoUrl}"; } else { $github_access_token = generateGithubInstallationToken($this->source); + $encodedToken = rawurlencode($github_access_token); if ($exec_in_docker) { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $base_command = "{$base_command} {$escapedRepoUrl}"; $fullRepoUrl = $repoUrl; } else { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl); $base_command = "{$base_command} {$escapedRepoUrl}"; $fullRepoUrl = $repoUrl; @@ -1189,6 +1190,62 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ 'fullRepoUrl' => $fullRepoUrl, ]; } + + if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) { + $gitlabSource = $this->source; + $private_key = data_get($gitlabSource, 'privateKey.private_key'); + + if ($private_key) { + $fullRepoUrl = $customRepository; + $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}'"; + + 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'), + ]); + } else { + $commands = collect([ + 'mkdir -p /root/.ssh', + "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", + 'chmod 600 /root/.ssh/id_rsa', + ]); + } + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $base_command)); + } else { + $commands->push($base_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + + // GitLab source without private key — use URL as-is (supports user-embedded basic auth) + $fullRepoUrl = $customRepository; + $escapedCustomRepository = escapeshellarg($customRepository); + $base_command = "{$base_command} {$escapedCustomRepository}"; + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $base_command)); + } else { + $commands->push($base_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } } if ($this->deploymentType() === 'deploy_key') { @@ -1301,13 +1358,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } } else { $github_access_token = generateGithubInstallationToken($this->source); + $encodedToken = rawurlencode($github_access_token); if ($exec_in_docker) { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } else { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; @@ -1339,6 +1397,77 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req 'fullRepoUrl' => $fullRepoUrl, ]; } + + if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) { + $gitlabSource = $this->source; + $private_key = data_get($gitlabSource, 'privateKey.private_key'); + + if ($private_key) { + $fullRepoUrl = $customRepository; + $private_key = base64_encode($private_key); + $gitlabPort = $gitlabSource->custom_port ?? 22; + $escapedCustomRepository = escapeshellarg($customRepository); + $git_clone_command_base = "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_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + if ($only_checkout) { + $git_clone_command = $git_clone_command_base; + } else { + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit); + } + 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'), + ]); + } else { + $commands = collect([ + 'mkdir -p /root/.ssh', + "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", + 'chmod 600 /root/.ssh/id_rsa', + ]); + } + + if ($pull_request_id !== 0) { + $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; + 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 {$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); + } + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + } else { + $commands->push($git_clone_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + + // GitLab source without private key — use URL as-is (supports user-embedded basic auth) + $fullRepoUrl = $customRepository; + $escapedCustomRepository = escapeshellarg($customRepository); + $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + } else { + $commands->push($git_clone_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } } if ($this->deploymentType() === 'deploy_key') { $fullRepoUrl = $customRepository; diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 217c82929..f819df380 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -275,9 +275,9 @@ function remove_iip($text) // ANSI color codes $text = preg_replace('/\x1b\[[0-9;]*m/', '', $text); - // Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, etc.) + // Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, git basic auth, etc.) // (protocol://user:password@host → protocol://user:@host) - $text = preg_replace('/((?:postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text); + $text = preg_replace('/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text); // Email addresses $text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index ce40466b2..b58f2ab7f 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -8,6 +8,7 @@ use App\Models\ApplicationPreview; use App\Models\EnvironmentVariable; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\InstanceSettings; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -3522,7 +3523,7 @@ function defaultNginxConfiguration(string $type = 'static'): string } } -function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp $source = null): array +function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|null $source = null): array { $repository = $gitRepository; $providerInfo = [ @@ -3542,6 +3543,7 @@ function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp // Let's try and fix that for known Git providers switch ($source->getMorphClass()) { case \App\Models\GithubApp::class: + case \App\Models\GitlabApp::class: $providerInfo['host'] = Url::fromString($source->html_url)->getHost(); $providerInfo['port'] = $source->custom_port; $providerInfo['user'] = $source->custom_user; diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 18ffbe166..70fb13a0d 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -4,6 +4,7 @@ use App\Models\Application; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\StandaloneDocker; use Illuminate\Database\Seeder; @@ -98,5 +99,36 @@ public function run(): void CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"] ', ]); + Application::create([ + 'uuid' => 'gitlab-deploy-key', + 'name' => 'GitLab Deploy Key Example', + 'fqdn' => 'http://gitlab-deploy-key.127.0.0.1.sslip.io', + 'git_repository' => 'git@gitlab.com:coollabsio/php-example.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GitlabApp::class, + 'private_key_id' => 1, + ]); + Application::create([ + 'uuid' => 'gitlab-public-example', + 'name' => 'GitLab Public Example', + 'fqdn' => 'http://gitlab-public.127.0.0.1.sslip.io', + 'git_repository' => 'https://gitlab.com/andrasbacsai/coolify-examples.git', + 'base_directory' => '/astro/static', + 'publish_directory' => '/dist', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GitlabApp::class, + ]); } } diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php index 8e439fd16..87236df8a 100644 --- a/database/seeders/ApplicationSettingsSeeder.php +++ b/database/seeders/ApplicationSettingsSeeder.php @@ -15,5 +15,12 @@ public function run(): void $application_1 = Application::find(1)->load(['settings']); $application_1->settings->is_debug_enabled = false; $application_1->settings->save(); + + $gitlabPublic = Application::where('uuid', 'gitlab-public-example')->first(); + if ($gitlabPublic) { + $gitlabPublic->load(['settings']); + $gitlabPublic->settings->is_static = true; + $gitlabPublic->settings->save(); + } } } diff --git a/tests/Unit/GitlabSourceCommandsTest.php b/tests/Unit/GitlabSourceCommandsTest.php new file mode 100644 index 000000000..077b21590 --- /dev/null +++ b/tests/Unit/GitlabSourceCommandsTest.php @@ -0,0 +1,91 @@ +makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $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($deploymentUuid, false); + + expect($result)->toBeArray(); + expect($result)->toHaveKey('commands'); + expect($result['commands'])->toContain('git ls-remote'); + expect($result['commands'])->toContain('id_rsa'); + expect($result['commands'])->toContain('mkdir -p /root/.ssh'); +}); + +it('generates ls-remote commands for GitLab source without private key', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'https://gitlab.com/user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $result = $application->generateGitLsRemoteCommands($deploymentUuid, false); + + expect($result)->toBeArray(); + expect($result)->toHaveKey('commands'); + expect($result['commands'])->toContain('git ls-remote'); + expect($result['commands'])->toContain('https://gitlab.com/user/repo.git'); + // Should NOT contain SSH key setup + expect($result['commands'])->not->toContain('id_rsa'); +}); + +it('does not return null for GitLab source type', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'https://gitlab.com/user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $lsRemoteResult = $application->generateGitLsRemoteCommands($deploymentUuid, false); + expect($lsRemoteResult)->not->toBeNull(); + expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']); +}); diff --git a/tests/Unit/SanitizeLogsForExportTest.php b/tests/Unit/SanitizeLogsForExportTest.php index 39d16c993..285230ea4 100644 --- a/tests/Unit/SanitizeLogsForExportTest.php +++ b/tests/Unit/SanitizeLogsForExportTest.php @@ -153,6 +153,22 @@ expect($result)->toContain('aws_secret_access_key='.REDACTED); }); +it('removes HTTPS basic auth passwords from git URLs', function () { + $testCases = [ + 'https://oauth2:glpat-xxxxxxxxxxxx@gitlab.com/user/repo.git' => 'https://oauth2:'.REDACTED.'@'.REDACTED, + 'https://user:my-secret-token@gitlab.example.com/group/repo.git' => 'https://user:'.REDACTED.'@'.REDACTED, + 'http://deploy:token123@git.internal.com/repo.git' => 'http://deploy:'.REDACTED.'@'.REDACTED, + ]; + + foreach ($testCases as $input => $notExpected) { + $result = sanitizeLogsForExport($input); + // The password should be redacted + expect($result)->not->toContain('glpat-xxxxxxxxxxxx'); + expect($result)->not->toContain('my-secret-token'); + expect($result)->not->toContain('token123'); + } +}); + it('removes generic URL passwords', function () { $testCases = [ 'ftp://user:ftppass@ftp.example.com/path' => 'ftp://user:'.REDACTED.'@ftp.example.com/path',