diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 2667b944a6..c392558620 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -61,6 +61,7 @@ class StartDragonfly $server = $this->database->destination->server; $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); + array_push($this->commands, ...SslHelper::caCertificateFileCommands($caCert->ssl_certificate)); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 38fc9d0f2d..b1b3e85ef8 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -61,6 +61,7 @@ class StartKeydb $server = $this->database->destination->server; $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); + array_push($this->commands, ...SslHelper::caCertificateFileCommands($caCert->ssl_certificate)); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 7e88b10cf5..6dd7842fed 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -61,6 +61,7 @@ class StartMariadb $server = $this->database->destination->server; $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); + array_push($this->commands, ...SslHelper::caCertificateFileCommands($caCert->ssl_certificate)); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index df9bbd7db0..9403b016fd 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -68,6 +68,7 @@ class StartMongodb $server = $this->database->destination->server; $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); + array_push($this->commands, ...SslHelper::caCertificateFileCommands($caCert->ssl_certificate)); $this->ssl_certificate = $this->database->sslCertificates()->first(); if (! $this->ssl_certificate) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index dc6b8311ce..ef0ca2cf3d 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -63,6 +63,7 @@ class StartMysql $server = $this->database->destination->server; $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); + array_push($this->commands, ...SslHelper::caCertificateFileCommands($caCert->ssl_certificate)); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 5db827be07..abae4630b1 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -67,6 +67,7 @@ class StartPostgresql $server = $this->database->destination->server; $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); + array_push($this->commands, ...SslHelper::caCertificateFileCommands($caCert->ssl_certificate)); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index c17936a128..14f31f64ac 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -63,6 +63,7 @@ class StartRedis $server = $this->database->destination->server; $caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate(); + array_push($this->commands, ...SslHelper::caCertificateFileCommands($caCert->ssl_certificate)); $this->ssl_certificate = $this->database->sslCertificates()->first(); diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 552445d728..3ff67cecd0 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -25,18 +25,7 @@ class InstallDocker isCaCertificate: true, validityDays: 10 * 365 ); - $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; - - $base64Cert = base64_encode($serverCert->ssl_certificate); - - $commands = collect([ - "mkdir -p $caCertPath", - "chown -R 9999:root $caCertPath", - "chmod -R 700 $caCertPath", - "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", - "chmod 644 $caCertPath/coolify-ca.crt", - ]); + $commands = SslHelper::caCertificateFileCommands($serverCert->ssl_certificate); remote_process($commands, $server); } diff --git a/app/Helpers/SslHelper.php b/app/Helpers/SslHelper.php index edae3db580..13ad941f5f 100644 --- a/app/Helpers/SslHelper.php +++ b/app/Helpers/SslHelper.php @@ -233,4 +233,24 @@ class SslHelper fclose($tempConfig); } } + + /** + * Commands that write the CA certificate to the shared CA file on the server. + * + * @return array + */ + public static function caCertificateFileCommands(string $certificate): array + { + $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($certificate); + + return [ + "mkdir -p $caCertPath", + "chown -R 9999:root $caCertPath", + "chmod -R 700 $caCertPath", + "rm -rf $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", + "chmod 644 $caCertPath/coolify-ca.crt", + ]; + } } diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php index 3f6ca8e472..cb4a896185 100644 --- a/app/Jobs/RegenerateSslCertJob.php +++ b/app/Jobs/RegenerateSslCertJob.php @@ -65,6 +65,7 @@ class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue mountPath: $certificate->mount_path, caCert: $caCert->ssl_certificate, caKey: $caCert->ssl_private_key, + isPemKeyFileRequired: $certificate->requiresPemKeyFile(), ); $resource = $certificate->database; if ($resource) { diff --git a/app/Livewire/Server/CaCertificate/Show.php b/app/Livewire/Server/CaCertificate/Show.php index 57aaaa945c..c30de14d3b 100644 --- a/app/Livewire/Server/CaCertificate/Show.php +++ b/app/Livewire/Server/CaCertificate/Show.php @@ -71,6 +71,10 @@ class Show extends Component $this->certificateContent = $cleanedCertificate; if ($this->caCertificate) { + if (! openssl_x509_check_private_key($this->certificateContent, $this->caCertificate->ssl_private_key)) { + throw new \Exception('This certificate does not match the CA private key of this server. Coolify signs database certificates with that key, so clients would fail with a certificate signature error.'); + } + $this->caCertificate->ssl_certificate = $this->certificateContent; $this->caCertificate->save(); @@ -118,18 +122,7 @@ class Show extends Component private function writeCertificateToServer() { - $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; - - $base64Cert = base64_encode($this->certificateContent); - - $commands = collect([ - "mkdir -p $caCertPath", - "chown -R 9999:root $caCertPath", - "chmod -R 700 $caCertPath", - "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", - "chmod 644 $caCertPath/coolify-ca.crt", - ]); + $commands = SslHelper::caCertificateFileCommands($this->certificateContent); remote_process($commands, $this->server); } diff --git a/app/Models/Server.php b/app/Models/Server.php index abce83cd37..f4004713da 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2152,18 +2152,7 @@ $siteAddress { $caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first(); if ($caCertificate) { $certificateContent = $caCertificate->ssl_certificate; - $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; - - $base64Cert = base64_encode($certificateContent); - - $commands = collect([ - "mkdir -p $caCertPath", - "chown -R 9999:root $caCertPath", - "chmod -R 700 $caCertPath", - "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", - "chmod 644 $caCertPath/coolify-ca.crt", - ]); + $commands = SslHelper::caCertificateFileCommands($certificateContent); instant_remote_process($commands, $this, false); diff --git a/app/Models/SslCertificate.php b/app/Models/SslCertificate.php index 2311cea729..fb6f5c1971 100644 --- a/app/Models/SslCertificate.php +++ b/app/Models/SslCertificate.php @@ -50,4 +50,13 @@ class SslCertificate extends Model { return $this->belongsTo(Server::class); } + + /** + * Whether the database reads its certificate and key from one combined server.pem file + * instead of separate server.crt and server.key files. + */ + public function requiresPemKeyFile(): bool + { + return $this->resource_type === StandaloneMongodb::class; + } } diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php index de9374a87f..b345657c4b 100644 --- a/app/Traits/HasDatabaseStatusInfo.php +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -157,7 +157,7 @@ trait HasDatabaseStatusInfo caKey: $caCert->ssl_private_key, configurationDir: $existingCert->configuration_dir, mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, + isPemKeyFileRequired: $existingCert->requiresPemKeyFile(), ); $this->refresh(); diff --git a/database/seeders/CaSslCertSeeder.php b/database/seeders/CaSslCertSeeder.php index 5d092d2e8a..84e8de59cf 100644 --- a/database/seeders/CaSslCertSeeder.php +++ b/database/seeders/CaSslCertSeeder.php @@ -24,18 +24,7 @@ class CaSslCertSeeder extends Seeder } else { $caCert = $existingCaCert; } - $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; - - $base64Cert = base64_encode($caCert->ssl_certificate); - - $commands = collect([ - "mkdir -p $caCertPath", - "chown -R 9999:root $caCertPath", - "chmod -R 700 $caCertPath", - "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", - "chmod 644 $caCertPath/coolify-ca.crt", - ]); + $commands = SslHelper::caCertificateFileCommands($caCert->ssl_certificate); remote_process($commands, $server); } diff --git a/tests/Feature/DatabaseSslCertificateRegenerateFilesTest.php b/tests/Feature/DatabaseSslCertificateRegenerateFilesTest.php new file mode 100644 index 0000000000..5ff7a9ab23 --- /dev/null +++ b/tests/Feature/DatabaseSslCertificateRegenerateFilesTest.php @@ -0,0 +1,137 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + $this->server->generateCaCertificate(); +}); + +dataset('ssl-regenerate-databases', [ + 'postgresql' => [ + StandalonePostgresql::class, + PostgresqlStatusInfo::class, + ['postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'postgres', 'image' => 'postgres:16-alpine'], + '/var/lib/postgresql/certs', + ['server.crt', 'server.key'], + ], + 'redis' => [ + StandaloneRedis::class, + RedisStatusInfo::class, + ['image' => 'redis:7.2'], + '/etc/redis/certs', + ['server.crt', 'server.key'], + ], + 'mongodb' => [ + StandaloneMongodb::class, + MongodbStatusInfo::class, + ['mongo_initdb_root_username' => 'root', 'mongo_initdb_root_password' => 'password', 'image' => 'mongo:7'], + '/etc/mongo/certs', + ['server.pem'], + ], +]); + +function createSslDatabase(object $test, string $modelClass, array $attributes, string $mountPath, array $expectedFiles): mixed +{ + $database = $modelClass::create([ + 'name' => 'ssl-regenerate-test', + 'status' => 'exited:unhealthy', + 'enable_ssl' => true, + 'environment_id' => $test->environment->id, + 'destination_id' => $test->destination->id, + 'destination_type' => $test->destination->getMorphClass(), + ...$attributes, + ]); + + $caCert = $test->server->sslCertificates()->where('is_ca_certificate', true)->firstOrFail(); + + SslHelper::generateSslCertificate( + commonName: $database->uuid, + resourceType: $database->getMorphClass(), + resourceId: $database->id, + serverId: $test->server->id, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + configurationDir: '/data/coolify/databases/'.$database->uuid, + mountPath: $mountPath, + isPemKeyFileRequired: $expectedFiles === ['server.pem'], + ); + + return $database; +} + +function mountedSslFiles(mixed $database, string $mountPath): array +{ + return $database->fileStorages() + ->where('mount_path', 'like', $mountPath.'/%') + ->pluck('mount_path') + ->sort() + ->values() + ->all(); +} + +it('regenerates the SSL certificate files that the database engine expects', function ( + string $modelClass, + string $componentClass, + array $attributes, + string $mountPath, + array $expectedFiles, +) { + $database = createSslDatabase($this, $modelClass, $attributes, $mountPath, $expectedFiles); + + Livewire::test($componentClass, ['database' => $database]) + ->call('regenerateSslCertificate') + ->assertDispatched('success'); + + expect(mountedSslFiles($database, $mountPath)) + ->toBe(array_map(fn (string $file) => $mountPath.'/'.$file, $expectedFiles)); +})->with('ssl-regenerate-databases'); + +it('renews the SSL certificate files that the database engine expects', function ( + string $modelClass, + string $componentClass, + array $attributes, + string $mountPath, + array $expectedFiles, +) { + $database = createSslDatabase($this, $modelClass, $attributes, $mountPath, $expectedFiles); + $database->sslCertificates()->update(['valid_until' => now()->addDays(3)]); + + (new RegenerateSslCertJob(server_id: $this->server->id))->handle(); + + expect($database->sslCertificates()->first()->valid_until->isAfter(now()->addDays(14)))->toBeTrue() + ->and(mountedSslFiles($database, $mountPath)) + ->toBe(array_map(fn (string $file) => $mountPath.'/'.$file, $expectedFiles)); +})->with('ssl-regenerate-databases'); diff --git a/tests/Feature/DatabaseStartCaCertificateFileTest.php b/tests/Feature/DatabaseStartCaCertificateFileTest.php new file mode 100644 index 0000000000..6cb1c78018 --- /dev/null +++ b/tests/Feature/DatabaseStartCaCertificateFileTest.php @@ -0,0 +1,76 @@ + 0]); + config(['app.maintenance.store' => 'array', 'cache.default' => 'array']); + Process::fake(); + Queue::fake(); + + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->executor = new class + { + public array $commands = []; + + public function execute(array $commands, $database, Activity $activity): Activity + { + $this->commands = $commands; + + return $activity; + } + }; + app()->instance(DatabaseStartCommandExecutor::class, $this->executor); +}); + +dataset('ssl-database-start-actions', [ + 'postgresql' => ['create_standalone_postgresql', StartPostgresql::class], + 'mysql' => ['create_standalone_mysql', StartMysql::class], + 'mariadb' => ['create_standalone_mariadb', StartMariadb::class], + 'mongodb' => ['create_standalone_mongodb', StartMongodb::class], + 'redis' => ['create_standalone_redis', StartRedis::class], + 'keydb' => ['create_standalone_keydb', StartKeydb::class], + 'dragonfly' => ['create_standalone_dragonfly', StartDragonfly::class], +]); + +it('writes the CA that signs the database certificate to the shared CA file on start', function (string $createDatabase, string $startAction) { + $this->server->generateCaCertificate(); + $caCertificate = $this->server->sslCertificates()->where('is_ca_certificate', true)->firstOrFail(); + + $database = $createDatabase($this->environment->id, $this->destination); + $database->update(['enable_ssl' => true]); + + $startAction::run($database->fresh(), new Activity); + + $databaseCertificate = $database->sslCertificates()->firstOrFail()->ssl_certificate; + + expect(openssl_x509_verify($databaseCertificate, openssl_pkey_get_public($caCertificate->ssl_certificate)))->toBe(1) + ->and(implode("\n", $this->executor->commands)) + ->toContain("echo '".base64_encode($caCertificate->ssl_certificate)."' | base64 -d | tee "); +})->with('ssl-database-start-actions'); diff --git a/tests/Feature/Security/CaCertificateCommandInjectionTest.php b/tests/Feature/Security/CaCertificateCommandInjectionTest.php index 91e529ef7e..b190bb08de 100644 --- a/tests/Feature/Security/CaCertificateCommandInjectionTest.php +++ b/tests/Feature/Security/CaCertificateCommandInjectionTest.php @@ -28,24 +28,28 @@ beforeEach(function () { ]); }); -function generateSelfSignedCert(): string +/** + * @return array{0: string, 1: string} The certificate and its private key. + */ +function generateSelfSignedCert(): array { $key = openssl_pkey_new(['private_key_bits' => 2048]); $csr = openssl_csr_new(['CN' => 'Test CA'], $key); $cert = openssl_csr_sign($csr, null, $key, 365); openssl_x509_export($cert, $certPem); + openssl_pkey_export($key, $keyPem); - return $certPem; + return [$certPem, $keyPem]; } test('saveCaCertificate sanitizes injected commands after certificate marker', function () { - $validCert = generateSelfSignedCert(); + [$validCert, $validKey] = generateSelfSignedCert(); $caCert = SslCertificate::create([ 'server_id' => $this->server->id, 'is_ca_certificate' => true, 'ssl_certificate' => $validCert, - 'ssl_private_key' => 'test-key', + 'ssl_private_key' => $validKey, 'common_name' => 'Coolify CA Certificate', 'valid_until' => now()->addYears(10), ]); @@ -97,3 +101,26 @@ test('saveCaCertificate rejects empty certificate content', function () { ->call('saveCaCertificate') ->assertDispatched('error'); }); + +test('saveCaCertificate rejects a certificate that does not match the CA private key', function () { + [$storedCert, $storedKey] = generateSelfSignedCert(); + [$otherCert] = generateSelfSignedCert(); + + $caCert = SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => true, + 'ssl_certificate' => $storedCert, + 'ssl_private_key' => $storedKey, + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYears(10), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('certificateContent', $otherCert) + ->call('saveCaCertificate') + ->assertDispatched('error') + ->assertNotDispatched('success'); + + expect($caCert->refresh()->ssl_certificate)->toBe($storedCert); + Queue::assertNothingPushed(); +});