mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user