fix(databases): keep SSL certificate files and CA in sync with each engine

- Regenerate SSL Certificates now writes server.crt/server.key for every
  engine except MongoDB, which keeps the combined server.pem. Before, the
  button always wrote server.pem, so Postgres, MySQL, MariaDB, Redis, KeyDB
  and Dragonfly failed to start after a regenerate.
- The certificate renewal job uses the same rule, so MongoDB no longer
  switches to server.crt/server.key on renewal and fails on restart.
- Starting an SSL database writes the CA that signs its certificate to
  /data/coolify/ssl/coolify-ca.crt, so a stale CA file (for example one
  written by another server entry for the same host) no longer causes
  "certificate signature failure".
- Saving a custom CA certificate that does not match the stored CA
  private key is rejected.
- The CA file write commands live in SslHelper::caCertificateFileCommands().

Fixes #10388
Fixes #6771
Fixes #11926
This commit is contained in:
Andras Bacsai
2026-09-30 19:01:11 +02:00
parent c221b22503
commit a2cd6351b2
18 changed files with 290 additions and 53 deletions
+1
View File
@@ -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();
+1
View File
@@ -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();
+1
View File
@@ -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();
+1
View File
@@ -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) {
+1
View File
@@ -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();
+1
View File
@@ -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();
+1
View File
@@ -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();
+1 -12
View File
@@ -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);
}
+20
View File
@@ -233,4 +233,24 @@ class SslHelper
fclose($tempConfig);
}
}
/**
* Commands that write the CA certificate to the shared CA file on the server.
*
* @return array<int, string>
*/
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",
];
}
}
+1
View File
@@ -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) {
+5 -12
View File
@@ -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);
}
+1 -12
View File
@@ -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);
+9
View File
@@ -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;
}
}
+1 -1
View File
@@ -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();
+1 -12
View File
@@ -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);
}
@@ -0,0 +1,137 @@
<?php
use App\Helpers\SslHelper;
use App\Jobs\RegenerateSslCertJob;
use App\Livewire\Project\Database\Mongodb\StatusInfo as MongodbStatusInfo;
use App\Livewire\Project\Database\Postgresql\StatusInfo as PostgresqlStatusInfo;
use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMongodb;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::forceCreate(['id' => 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');
@@ -0,0 +1,76 @@
<?php
use App\Actions\Database\StartDragonfly;
use App\Actions\Database\StartKeydb;
use App\Actions\Database\StartMariadb;
use App\Actions\Database\StartMongodb;
use App\Actions\Database\StartMysql;
use App\Actions\Database\StartPostgresql;
use App\Actions\Database\StartRedis;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Services\DatabaseStartCommandExecutor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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');
@@ -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();
});